diff --git a/CAROUSEL_DASHBOARD_PLAN.md b/CAROUSEL_DASHBOARD_PLAN.md
index ab7b9ca..8eaed24 100644
--- a/CAROUSEL_DASHBOARD_PLAN.md
+++ b/CAROUSEL_DASHBOARD_PLAN.md
@@ -52,9 +52,8 @@ This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit us
- **TailwindCSS classes ONLY** - no custom CSS
- **TypeScript** in `web/src/` (no inline JavaScript)
- **Procedural/imperative style** - no OOP (classes, inheritance, this-capture)
-- **SSR for initial data** - no AJAX on page load
-- **Progressive enhancement** - works without JavaScript
-- **HTMX for CRUD operations** (library switching, settings updates)
+- **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)
- **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`
@@ -120,8 +119,17 @@ ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAU
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard)
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)
-CREATE TABLE smart_section_types (
+-- 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,
@@ -131,12 +139,13 @@ CREATE TABLE smart_section_types (
is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user
);
--- Insert default sections (4 smart sections + user collections)
+-- 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);
+('unread', 'Not Started', 'Books you haven''t read yet (progress = 0 or no record)', '📕', 4, false)
+ON CONFLICT (section_key) DO NOTHING;
```
#### 1.2 Regenerate Database Code
@@ -163,18 +172,23 @@ package services
import (
"context"
+ "encoding/json"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
- db *database.Queries
+ db *database.Queries
+ collectionService *CollectionService
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
- return &DashboardService{db: db}
+ return &DashboardService{
+ db: db,
+ collectionService: NewCollectionService(db),
+ }
}
// SectionItems contains raw items for a section - handler formats into SectionData
@@ -279,27 +293,147 @@ func (s *DashboardService) reorderSections(items []SectionItems, order []string)
}
func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
- // Query media items WHERE progress > 0 AND progress < 1
- // Ordered by last_read_at DESC
+ // 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
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
- // Query media items ORDER BY created_at DESC
+ // 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
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
- // Query media items WHERE progress >= 1 (completed)
+ // 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
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
- // Query media items WHERE progress = 0 OR no reading_progress record
+ // 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
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
- // Query collections WHERE show_on_dashboard = true
- // Return SectionItems for each collection
+ // 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
}
// GetDashboardPreferences fetches user preferences for a library
@@ -309,6 +443,11 @@ func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID,
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)
+}
```
**Key Points**:
@@ -385,6 +524,58 @@ VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET
show_on_dashboard = EXCLUDED.show_on_dashboard
RETURNING *;
+
+-- Smart section queries
+
+-- name: GetContinueReadingItems :many
+SELECT DISTINCT mi.* FROM media_items mi
+INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
+WHERE mi.library_id = $1
+ AND rp.user_id = $2
+ AND rp.percentage > 0
+ AND rp.percentage < 1
+ORDER BY rp.last_read_at DESC
+LIMIT $3;
+
+-- name: GetRecentlyAddedItems :many
+SELECT mi.* FROM media_items mi
+WHERE mi.library_id = $1
+ORDER BY mi.created_at DESC
+LIMIT $2;
+
+-- name: GetRecentlyReadItems :many
+SELECT DISTINCT mi.* FROM media_items mi
+INNER JOIN reading_progress rp ON rp.media_item_id = mi.id
+WHERE mi.library_id = $1
+ AND rp.user_id = $2
+ AND rp.percentage >= 1
+ORDER BY rp.last_read_at DESC
+LIMIT $3;
+
+-- name: GetUnreadItems :many
+SELECT mi.* FROM media_items mi
+WHERE mi.library_id = $1
+ AND NOT EXISTS (
+ SELECT 1 FROM reading_progress rp
+ WHERE rp.media_item_id = mi.id
+ AND rp.user_id = $2
+ AND rp.percentage > 0
+ )
+ORDER BY mi.created_at DESC
+LIMIT $3;
+
+-- name: GetCollectionItems :many
+SELECT mi.*, ci.excluded FROM media_items mi
+INNER JOIN collection_items ci ON ci.media_item_id = mi.id
+WHERE ci.collection_id = $1
+ AND mi.library_id = $2
+ORDER BY ci.added_at DESC
+LIMIT $3;
+
+-- name: GetLibraryItems :many
+SELECT mi.* FROM media_items mi
+WHERE mi.library_id = $1
+ORDER BY mi.created_at DESC;
```
Regenerate: `cd internal/database && sqlc generate`
@@ -487,40 +678,16 @@ func (h *DashboardHandler) GetSections(c echo.Context) error {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load sections"})
}
- // Convert to JSON response format
- sections := buildJSONSections(sectionItems)
+ // 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})
}
-// buildJSONSections converts service SectionItems to JSON-serializable format
-func buildJSONSections(items []services.SectionItems) []map[string]interface{} {
- sections := make([]map[string]interface{}, len(items))
+// 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
- for i, item := range items {
- // Convert database.MediaItems to simplified book format
- books := make([]map[string]interface{}, len(item.Items))
- for j, book := range item.Items {
- bookUUID, _ := uuid.FromBytes(book.ID.Bytes[0:16])
- books[j] = map[string]interface{}{
- "id": bookUUID.String(),
- "title": book.Title,
- "author": book.Author.String,
- "cover_image_path": book.CoverImagePath.String,
- }
- }
-
- sections[i] = map[string]interface{}{
- "id": item.SectionKey,
- "type": getSectionType(item.SectionKey),
- "title": getSectionTitle(item.SectionKey),
- "icon": getSectionIcon(item.SectionKey),
- "items": books,
- "view_all_url": getSectionViewAllURL(item.SectionKey),
- }
- }
-
- return sections
-}
// Helper functions for section metadata
func getSectionType(key string) string {
@@ -584,6 +751,93 @@ func getSectionViewAllURL(key string) string {
- ✅ 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)
@@ -709,6 +963,32 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error {
```
**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 {
@@ -739,7 +1019,43 @@ frontendProtected.GET("/settings", func(c echo.Context) error {
return c.HTML(http.StatusOK, buf.String())
})
-frontendProtected.POST("/settings", func(c echo.Context) error {
+**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")
@@ -1061,6 +1377,16 @@ templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboar
- ✅ **Event delegation pattern** (no inline onclick)
- ✅ **Data attributes** for TypeScript integration
+#### 9.1 Main Dashboard Template
+
+**COMPLIANCE**:
+- ✅ Use TailwindCSS classes ONLY (no custom CSS)
+- ✅ Use **handler types** (handlers.SectionData, handlers.BookInfo) - NO duplicate template types
+- ✅ SSR for initial data
+- ✅ HTMX for updates
+- ✅ **Event delegation pattern** (no inline onclick)
+- ✅ **Data attributes** for TypeScript integration
+
#### 8.1 Main Dashboard Template
**File: `templates/dashboard.templ`** (REPLACE existing)
@@ -1093,13 +1419,10 @@ templ Dashboard(user User, sections []handlers.SectionData, libraries []LibraryD
-
-
+
-
+
for _, section := range sections {
@SectionCarousel(section)
@@ -1309,6 +1632,20 @@ templ DashboardSettingsModal(sections []handlers.SectionData) {
}
+
+
+