Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe d981fdf517 docs(dashboard): update Carousel plan with API endpoint and user preferences
Major architectural improvements:

1. Add generic /api/dashboard/sections JSON endpoint
   - Created internal/handlers/dashboard.go (new file)
   - Created internal/router/dashboard.go (new file)
   - Single source of truth for web UI, mobile apps, plugins
   - Follows existing handler/router pattern

2. Update DashboardService to apply user preferences
   - GetSectionItems() now accepts sectionOrder and hiddenSections
   - filterHiddenSections() removes user's hidden sections
   - reorderSections() applies user's custom order
   - Ensures consistent behavior across all clients

3. Separate concerns properly
   - API handlers in internal/handlers/dashboard.go
   - SSR routes remain in internal/router/frontend.go
   - Both use same DashboardService (single source of truth)

4. Reorganize implementation phases
   - Phase 1-3: Database, service, queries
   - Phase 4-6: Handler, router, frontend routes
   - Phase 7-9: Templates and settings
   - Phase 10-11: TypeScript modules
   - Phase 12-13: Documentation and testing

5. Add documentation
   - docs/developer/api/dashboard.md (API reference)
   - docs/user/dashboard.md (user guide)

6. Bruno tests already exist
   - bruno/dashboard/ has 5 comprehensive test files
   - Three-context testing (no user, user, admin)
   - No additional tests needed

Benefits:
- Uniform dashboard across web, mobile, plugins
- Single source of truth (no duplicate logic)
- User preferences respected by all clients
- Follows established project patterns
- Comprehensive test coverage

Timeline: Updated to reflect 13 phases (19-25 days total with TypeScript)
2026-02-17 22:20:54 -05:00

2058 lines
74 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🎬 Carousel-Style Dashboard Redesign Plan
## Overview
Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with:
- Smart sections (Continue Reading, Recently Added, etc.)
- User collections as sections
- Filter-based smart sections (custom collections with auto-assign rules)
- Separate dashboard per library
- Full accessibility, keyboard nav, and touch gestures
- **SSR-first architecture** (data pre-populated server-side, HTMX for updates)
---
## ⚠️ Prerequisites: TypeScript Conversion First
**IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first.
**Required Infrastructure from TypeScript Conversion Plan:**
-`web/ts/core/api.ts` - Centralized API client with auth
-`web/ts/core/toast.ts` - Toast notification system
-`web/ts/shared/events.ts` - Event delegation utilities
-`web/ts/core/storage.ts` - localStorage wrapper
-`web/ts/core/dom.ts` - DOM utilities (escapeHtml, etc.)
- ✅ Event delegation pattern established (data attributes)
- ✅ TypeScript compilation pipeline in place (`npm run build:ts`)
**Execution Order:**
1. Complete TypeScript Conversion Plan (16-21 days)
2. Execute this updated Carousel Dashboard Plan (3-4 days)
**Timeline:** 19-25 days total (no rework, consistent patterns)
---
## 🏗️ Architecture Compliance
### Project Guidelines Alignment
This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.
**Key Compliance Points:**
**Full-Stack Task** (backend modifications approved):
- Database schema changes
- New service layer for reusable business logic
- New API endpoints for mobile app compatibility
- Bruno tests already created in `bruno/dashboard/`
**Frontend Standards** (Updated for Post-TypeScript Conversion):
- **TailwindCSS classes ONLY** - no custom CSS
- **TypeScript** in `web/ts/features/dashboard/` (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)
- **Event delegation pattern** - `data-action` attributes (no inline `onclick`)
- **Shared API client** - `apiClient` from `web/ts/core/api.ts`
**Code Organization**:
- **Template types in templates/types.go** - SectionData, BookCardData
- **All business logic in services** - reusable for SSR/API/mobile
- **TypeScript in web/ts/features/dashboard/** - follows TypeScript Conversion Plan structure
**Database Operations**:
- **Merge into existing schema.sql** - no migration files
- **Atomic schema changes** - complete success or rejection
- **Pre-production app** - database will be recreated after schema changes
- **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
---
## 📋 Implementation Plan
### **Phase 1: Database Schema Changes** (2-3 hours)
#### 1.1 Update Schema File (Not Migrations)
**File: `database/schema/schema.sql`** (MODIFY existing file)
**CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database:
```bash
podman compose down -v # Delete volumes (loses all data)
podman compose up -d # Start fresh with new schema
```
**Add to schema.sql**:
```sql
-- Table: user_dashboard_preferences
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 '{}',
items_per_section INT DEFAULT 20,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, library_id)
);
-- 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
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard)
WHERE show_on_dashboard = true;
-- Predefined smart sections (system-level, not user-created)
CREATE TABLE 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
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading', '📖', 1, false),
('in-progress', 'In Progress', 'Books you''ve started but not finished', '📚', 2, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 3, true),
('recently-read', 'Recently Read', 'Books you''ve finished', '✅', 4, false),
('unread', 'Not Started', 'Books you haven''t read yet', '📕', 5, false);
```
#### 1.2 Regenerate Database Code
```bash
cd internal/database
sqlc generate
```
Verify:
-`models.go` has new structs
-`queries.sql` is ready for new queries
- ✅ No compilation errors
---
### **Phase 2: Service Layer** (3-4 hours)
**File: `internal/services/dashboard_service.go`** (new file)
**COMPLIANCE**: All business logic in reusable service (per guidelines)
```go
package services
import (
"context"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{db: db}
}
// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
SectionKey string
Items []database.MediaItems
}
// GetSectionItems fetches raw items for each section type
// Accepts user preferences to customize order and visibility
// Handler will format these into template.SectionData
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)
) ([]SectionItems, error) {
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})
// 2. In Progress - items with progress > 0
inProgress, _ := s.getInProgress(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "in-progress", Items: inProgress})
// 3. Recently Added - newest items in library
recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded})
// 4. Recently Read - items with progress = 1
recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead})
// 5. Not Started - items with no progress
unread, _ := s.getUnread(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "unread", Items: unread})
// 6. User collections marked for dashboard
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
results = append(results, collectionItems...)
// Apply user preferences: filter hidden sections
results = s.filterHiddenSections(results, hiddenSections)
// Apply user preferences: reorder sections
results = s.reorderSections(results, sectionOrder)
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
}
// 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
}
// Create ordered result
var ordered []SectionItems
remaining := make(map[string]SectionItems)
for _, item := range items {
remaining[item.SectionKey] = item
}
// Add sections in user's preferred order
for _, key := range order {
if item, exists := remaining[key]; exists {
ordered = append(ordered, item)
delete(remaining, key)
}
}
// 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)
}
}
return ordered
}
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
}
func (s *DashboardService) getInProgress(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress > 0
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items ORDER BY created_at DESC
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress >= 1 (completed)
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items with no reading_progress record
}
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
}
// 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},
})
}
```
**Key Points**:
- ✅ Service layer holds all business logic
- ✅ 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
---
### **Phase 3: Database Queries** (1-2 hours)
**File: `internal/database/queries/queries.sql`** (ADD to existing file)
```sql
-- name: GetDashboardPreferences :one
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)
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,
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,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
ORDER BY c.created_at DESC;
-- 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 *;
```
Regenerate: `cd internal/database && sqlc generate`
---
### **Phase 4: API Handler** (1-2 hours)
**File: `internal/handlers/dashboard.go`** (new file)
**COMPLIANCE**: Generic API handler for reuse by SSR, mobile, plugins
```go
package handlers
import (
"net/http"
"strconv"
"bookhoard/internal/database"
"bookhoard/internal/services"
"bookhoard/templates"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
"github.com/labstack/echo/v4"
)
type DashboardHandler struct {
db *database.Queries
dashboardService *services.DashboardService
}
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
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)
// 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"})
}
// Get user's dashboard preferences (customization)
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
}
}
// 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"})
}
// Convert to JSON response format
sections := buildJSONSections(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))
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 {
// Return "smart" or "collection" based on key
smartSections := map[string]bool{
"continue-reading": true,
"in-progress": true,
"recently-added": true,
"recently-read": true,
"unread": true,
}
if smartSections[key] {
return "smart"
}
return "collection"
}
func getSectionTitle(key string) string {
titles := map[string]string{
"continue-reading": "Continue Reading",
"in-progress": "In Progress",
"recently-added": "Recently Added",
"recently-read": "Recently Read",
"unread": "Not Started",
}
if title, exists := titles[key]; exists {
return title
}
return key // Collection name
}
func getSectionIcon(key string) string {
icons := map[string]string{
"continue-reading": "📖",
"in-progress": "📚",
"recently-added": "🆕",
"recently-read": "✅",
"unread": "📕",
}
if icon, exists := icons[key]; exists {
return icon
}
return "📚" // Default collection icon
}
func getSectionViewAllURL(key string) string {
urls := map[string]string{
"continue-reading": "/section/continue-reading",
"in-progress": "/section/in-progress",
"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
}
```
**Key Points**:
- ✅ Generic JSON API endpoint
- ✅ Applies user preferences (order, hidden sections)
- ✅ Reusable by mobile apps, web UI, plugins
- ✅ Returns sections in user's customized order
- ✅ Respects hidden sections preference
---
### **Phase 5: API Router** (30 min)
**File: `internal/router/dashboard.go`** (new file)
**COMPLIANCE**: Follow existing router pattern (see router/collections.go)
```go
package router
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)
```
---
### **Phase 6: Frontend Routes (SSR)** (1-2 hours)
**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 template types)
sections := buildSections(sectionItems, prefs)
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):
```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())
})
frontendProtected.POST("/settings", func(c echo.Context) error {
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", "📖", "/section/continue-reading", 1},
"in-progress": {"In Progress", "Books you've started but not finished", "📚", "/section/in-progress", 2},
"recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 3},
"recently-read": {"Recently Read", "Books you've finished", "✅", "/history", 4},
"unread": {"Not Started", "Books you haven't read yet", "📕", "/section/unread", 5},
}
// buildSections converts service SectionItems to template SectionData
func buildSections(items []services.SectionItems, prefs database.UserDashboardPreferences) []templates.SectionData {
var sections []templates.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 template.BookCardData
bookCards := make([]templates.BookCardData, len(si.Items))
for i, item := range si.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = templates.BookCardData{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
}
}
sections = append(sections, templates.SectionData{
ID: si.SectionKey,
Type: sectionType,
Title: title,
Description: description,
Icon: icon,
Items: bookCards,
ViewAllURL: viewAllURL,
Priority: priority,
})
}
return sections
}
```
---
### **Phase 7: Template Types** (30 min)
**File: `templates/types.go`** (ADD to existing file)
Add new types to support dashboard:
```go
// SectionData represents a dashboard section (carousel)
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // "smart", "collection"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []BookCardData `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
IsHidden bool `json:"is_hidden"`
}
// BookCardData represents a book in a carousel card
type BookCardData struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
```
---
### **Phase 8: Settings Template** (2 hours)
**COMPLIANCE**: Use template types, TailwindSSR, SSR
**File: `templates/settings.templ`** (new file)
```templ
package templates
import (
"bookhoard/internal/database"
)
templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/settings")
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Settings</h1>
<form id="settings-form" data-action="save-settings">
<!-- Profile Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Profile</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" value={ userDB.Email }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" value={ userDB.Username }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" value={ userDB.FirstName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" value={ userDB.LastName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
</div>
</div>
<!-- Theme Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Appearance</h2>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Theme</label>
<select name="theme"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="tokyo-night" { user.Theme == "tokyo-night" { "selected" } }>Tokyo Night</option>
<option value="light" { user.Theme == "light" { "selected" } }>Light</option>
<option value="dark" { user.Theme == "dark" { "selected" } }>Dark</option>
</select>
</div>
</div>
<!-- Dashboard Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Dashboard Preferences</h2>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Items Per Section</label>
<input type="range" name="items_per_section" min="10" max="50" step="5"
value={ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }
class="w-full"
data-action="update-items-display"
target="items-display">
<div class="text-sm text-right mt-1" style="color: var(--text-secondary)">
<span id="items-display">{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }</span> items
</div>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end gap-3">
<button type="button" data-action="cancel"
class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Settings
</button>
</div>
</form>
</div>
<script src="/static/shared/events.js"></script>
<script src="/static/features/settings.js"></script>
<script src="/static/theme.js"></script>
</body>
</html>
}
```
---
### **Phase 9: Templates** (4-5 hours)
**COMPLIANCE**:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Use template types (SectionData, BookCardData, User, LibraryData)
- ✅ SSR for initial data
- ✅ HTMX for updates
-**Event delegation pattern** (no inline onclick)
-**Data attributes** for TypeScript integration
#### 6.1 Main Dashboard Template
**File: `templates/dashboard.templ`** (REPLACE existing)
```templ
package templates
templ Dashboard(user User, sections []SectionData, libraries []LibraryData, currentLibraryID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<script src="/static/core/api.js"></script>
<script src="/static/shared/events.js"></script>
<script src="/static/features/dashboard/carousel.js"></script>
<script src="/static/features/dashboard/settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select"
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);"
hx-get="/dashboard/sections?library_id={ currentLibraryID }"
hx-target="#sections-container"
hx-indicator="#loading-spinner"
hx-swap="innerHTML">
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
<button data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
🔄
</button>
</div>
</div>
<!-- HTMX Loading Indicator (hidden by default) -->
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);">
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
</div>
<!-- Sections Container (HTMX swap target) -->
<main id="sections-container" class="max-w-7xl mx-auto px-4 py-8">
for _, section := range sections {
@SectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
</body>
</html>
}
```
#### 6.2 Section Carousel Component
**File: `templates/components.templ`** (ADD to existing file if exists, or new file)
```templ
package templates
templ SectionCarousel(section SectionData) {
<div class="dashboard-section mb-8"
data-section-id={ section.ID }
data-section-type={ section.Type }>
<!-- Section Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
}
</div>
</div>
<a href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);">
View All
</a>
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<!-- Left Navigation -->
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
</button>
<!-- Track -->
<div id="carousel-track-{ section.ID }"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this section</p>
</div>
}
</div>
<!-- Right Navigation -->
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
</button>
</div>
</div>
}
templ BookCard(item BookCardData) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.ID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<!-- Cover -->
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
if item.CoverImagePath != "" {
<img src={ item.CoverImagePath }
alt={ item.Title }
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover">
}
</div>
<!-- Title -->
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
<!-- Author -->
if item.Author != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []SectionData) {
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors">
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder sections, toggle visibility with the switch.
</p>
<!-- Draggable Section List -->
<div id="section-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="section-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-section-id={ section.ID }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span>
<span class="text-xl">{ section.Icon }</span>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
checked
data-action="toggle-section-visibility"
data-section-id={ section.ID }>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Section: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
data-action="update-items-count"
target="items-count-display">
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
```
#### 6.3 HTMX Partial Template
**File: `templates/dashboard_sections_partial.templ`** (new file)
```templ
package templates
templ DashboardSectionsPartial(sections []SectionData) {
for _, section := range sections {
@SectionCarousel(section)
}
}
```
---
### **Phase 10: TypeScript** (REVISED - 2-3 hours)
**COMPLIANCE** (Post-TypeScript Conversion):
- ✅ TypeScript files in `web/ts/features/dashboard/` (not `web/src/`)
- ✅ Uses shared infrastructure from TypeScript Conversion Plan
- ✅ Event delegation pattern (no inline onclick handlers)
- ✅ Procedural/imperative style (no OOP)
- ✅ Type definitions matching Go handlers templates/types.go
- ✅ Uses `apiClient` from `web/ts/core/api.ts`
- ✅ Uses `showToast` from `web/ts/core/toast.ts`
- ✅ Uses `on` from `web/ts/shared/events.ts`
#### 7.1 Dashboard Carousel TypeScript
**File: `web/ts/features/dashboard/carousel.ts`** (new file, updated location)
```typescript
// Carousel scroll functionality
// Procedural/imperative style (no OOP)
// Uses shared event delegation system
import { on } from '../../shared/events.js';
const SCROLL_AMOUNT = 300;
// Pure function for scrolling carousel
const scrollCarousel = (sectionId: string, direction: number): void => {
const track = document.getElementById(`carousel-track-${sectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
};
// Initialize drag-to-scroll on all carousel tracks
const initializeCarousels = (): void => {
const tracks = document.querySelectorAll('.carousel-track') as NodeListOf<HTMLElement>;
tracks.forEach(track => {
let isDown = false;
let startX: number;
let scrollLeftPos: number;
track.addEventListener('mousedown', (e: MouseEvent) => {
isDown = true;
startX = e.pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('mouseleave', () => isDown = false);
track.addEventListener('mouseup', () => isDown = false);
track.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
// Touch events for mobile
track.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('touchmove', (e: TouchEvent) => {
const x = e.touches[0].pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
});
};
// Event delegation for carousel scroll buttons
on('click', '[data-action="scroll-carousel"]', (target: HTMLElement) => {
const sectionId = target.dataset.sectionId;
const direction = parseInt(target.dataset.direction || '0', 10);
if (sectionId && !isNaN(direction)) {
scrollCarousel(sectionId, direction);
}
});
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCarousels);
} else {
initializeCarousels();
}
}
```
#### 7.2 Dashboard Settings TypeScript
**File: `web/ts/features/dashboard/settings.ts`** (new file, updated location)
```typescript
// Dashboard settings modal functionality
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
import { apiClient } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { on } from '../../shared/events.js';
// Type definitions matching Go templates/types.go
interface SectionData {
ID: string;
Title: string;
Icon: string;
}
// Open dashboard settings modal
const openDashboardSettings = (): void => {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.remove('hidden');
initializeDragAndDrop();
}
};
// Close dashboard settings modal
const closeDashboardSettings = (): void => {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.add('hidden');
}
};
// Initialize drag-and-drop for section reordering
const initializeDragAndDrop = (): void => {
const list = document.getElementById('section-list');
if (!list) return;
const items = list.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
};
// Drag event handlers
const handleDragStart = (e: DragEvent): void => {
const target = e.target as HTMLElement;
target.style.opacity = '0.5';
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
}
};
const handleDragOver = (e: DragEvent): void => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e: DragEvent): void => {
e.preventDefault();
// TODO: Implement reorder logic - swap with target element
};
const handleDragEnd = (e: DragEvent): void => {
const target = e.target as HTMLElement;
target.style.opacity = '1';
};
// Toggle section visibility (checkbox handler)
const toggleSectionVisibility = (sectionId: string): void => {
// Update local state, save on submit
// The checkbox state is managed by HTML
};
// Save dashboard settings to server
const saveDashboardSettings = async (): Promise<void> => {
const sectionList = document.getElementById('section-list');
const items = sectionList?.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;
const sectionOrder: string[] = [];
const hiddenSections: string[] = [];
items?.forEach(item => {
const id = item.getAttribute('data-section-id');
if (!id) return;
sectionOrder.push(id);
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox && !checkbox.checked) {
hiddenSections.push(id);
}
});
const data = {
library_id: getCurrentLibraryId(),
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt(document.getElementById('items-count-display')?.textContent || '20')
};
try {
await apiClient.post('/settings', data);
showToast.success('Dashboard settings saved');
closeDashboardSettings();
location.reload();
} catch (error) {
showToast.error('Failed to save dashboard settings');
}
};
// Helper to get current library ID from selector
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for dashboard settings
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
on('click', '[data-action="close-dashboard-settings"]', () => {
closeDashboardSettings();
});
on('click', '[data-action="save-dashboard-settings"]', () => {
saveDashboardSettings();
});
on('input', '[data-action="update-items-display"]', (target: HTMLElement) => {
const displayElement = document.getElementById(target.dataset.target || 'items-display');
if (displayElement) {
displayElement.textContent = (target as HTMLInputElement).value;
}
});
```
#### 7.3 Settings Page TypeScript
**File: `web/ts/features/settings/settings.ts`** (new file)
```typescript
// Settings page form handling
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
import { apiClient } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { on } from '../../shared/events.js';
// Save user settings (email, username, theme, etc.)
const saveSettings = async (event: Event): Promise<void> => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const data = {
email: formData.get('email') as string,
username: formData.get('username') as string,
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
theme: formData.get('theme') as string,
// Dashboard preferences are saved separately via dashboard modal
library_id: getCurrentLibraryId(),
};
try {
const response = await apiClient.post('/settings', data);
// Response should contain updated user data including theme
showToast.success('Settings saved successfully');
// Update theme immediately
const theme = formData.get('theme') as string;
if (theme) {
document.body.className = `theme-${theme}`;
}
} catch (error) {
showToast.error('Failed to save settings');
}
};
// Helper to get current library ID
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for settings form
on('submit', '[data-action="save-settings"]', saveSettings);
on('click', '[data-action="cancel"]', () => {
history.back();
});
```
#### 7.4 Template Updates
**Update `templates/dashboard.templ` head section** (already shown above in Phase 6.1)
**Add `templates/settings.templ` head section**:
```templ
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<script src="/static/core/api.js"></script>
<script src="/static/shared/events.js"></script>
<script src="/static/features/settings/settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
```
**Key Points**:
- ✅ TypeScript files in `web/ts/features/dashboard/` structure (follows TypeScript Conversion Plan)
- ✅ Uses shared utilities (`apiClient`, `showToast`, `on` event delegation)
- ✅ Event delegation pattern (no onclick handlers, data-action attributes)
- ✅ Type-safe API calls and error handling
- ✅ Compiled via existing `npm run build:ts`
- ✅ Matches procedural/imperative style (no OOP)
---
### **Phase 11: Book Detail Page** (3-4 hours)
**File: `templates/book_detail.templ`** (new file)
**COMPLIANCE**: Use template types, TailwindCSS, SSR
```templ
package templates
import (
"bookhoard/internal/database"
"fmt"
)
templ BookDetail(user User, book database.MediaItems, progress database.ReadingProgress, rating float64, collections []CollectionData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{ book.Title } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "")
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Back Button -->
<button data-action="go-back"
class="mb-6 text-sm hover:underline transition-colors"
style="color: var(--text-secondary);">
Back to Dashboard
</button>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Cover Image -->
<div class="md:col-span-1">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-2xl">
if book.CoverImagePath.Valid {
<img src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-full h-full object-cover"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ book.Title }
class="w-full h-full object-cover">
}
</div>
</div>
<!-- Details -->
<div class="md:col-span-2">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);">
{ book.Title }
</h1>
if book.Author.Valid {
<p class="text-xl mb-4" style="color: var(--text-secondary);">
by { book.Author.String }
</p>
}
<!-- Reading Progress -->
if progress.Percentage > 0 {
<div class="mb-6 p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex justify-between text-sm mb-2">
<span style="color: var(--text-secondary);">Reading Progress</span>
<span style="color: var(--text-primary);">
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
</span>
</div>
<div class="w-full h-2 rounded-full" style="background-color: var(--bg-primary);">
<div class="h-full rounded-full transition-all"
style={ fmt.Sprintf("width: %.0f%%; background-color: var(--accent);", progress.Percentage * 100) }></div>
</div>
</div>
}
<!-- Actions -->
<div class="flex gap-2 mb-6">
<button data-action="add-to-collection"
class="px-4 py-2 rounded-lg border text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Add to Collection
</button>
<button data-action="mark-as-read"
class="px-4 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Mark as Read
</button>
</div>
<!-- Description/Synopsis -->
{ book.Synopsis.Valid }
<div class="p-4 mb-6 rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary);">
{ book.Synopsis.String }
</div>
{ end }
<!-- Collections -->
if len(collections) > 0 {
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-2">
for _, collection := range collections {
<span class="px-3 py-1 rounded-full text-sm" style="background-color: { collection.Color }; color: var(--text-primary);">
{ collection.Icon } { collection.Name }
</span>
}
</div>
</div>
}
</div>
</div>
</div>
<script src="/static/shared/events.js"></script>
<script src="/static/features/book/detail.js"></script>
</body>
</html>
}
```
---
### **Phase 12: Documentation** (1-2 hours)
**COMPLIANCE**: Update API documentation for new endpoint
#### 12.1 API Documentation
**File: `docs/developer/api/dashboard.md`** (new file)
```markdown
# Dashboard API
## Get Dashboard Sections
Retrieve all dashboard sections for a specific library, including smart sections and user collections.
**Endpoint**: `GET /api/dashboard/sections`
**Authentication**: Required (Bearer token)
### Query Parameters
| Parameter | Type | Required | Description |
|-----------|--------|----------|-----------------------------------------------|
| library_id| string | Yes | Library UUID to fetch sections for |
| limit | number | No | Items per section (default: 20, max: 100) |
### Response
Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).
**Section Types**:
- `smart`: Auto-generated sections based on reading activity
- `collection`: User-created collections with `show_on_dashboard: true`
**Smart Sections**:
| ID | Title | Icon | Description |
|-----------------|------------------|------|--------------------------------------------------|
| continue-reading| Continue Reading | 📖 | Books with progress > 0% and < 100% |
| in-progress | In Progress | 📚 | Books with progress > 0% |
| recently-added | Recently Added | 🆕 | Newest items in library |
| recently-read | Recently Read | ✅ | Books with progress = 100% |
| unread | Not Started | 📕 | Books with no reading progress |
### Example Response
\`\`\`json
{
"sections": [
{
"id": "continue-reading",
"type": "smart",
"title": "Continue Reading",
"icon": "📖",
"items": [
{
"id": "uuid-here",
"title": "Book Title",
"author": "Author Name",
"cover_image_path": "/path/to/cover.jpg"
}
],
"view_all_url": "/section/continue-reading"
},
{
"id": "collection-uuid",
"type": "collection",
"title": "My Favorites",
"icon": "⭐",
"items": [...],
"view_all_url": null
}
]
}
\`\`\`
### User Preferences
The endpoint respects user's dashboard preferences:
- **`section_order`**: Sections returned in user's custom order
- **`hidden_sections`**: Hidden sections excluded from response
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
### Error Responses
| Status | Description |
|--------|------------------------|
| 400 | Missing library_id |
| 400 | Invalid library_id |
| 401 | Unauthorized |
| 500 | Failed to load sections |
```
#### 12.2 User Documentation
**File: `docs/user/dashboard.md`** (new file)
```markdown
# Dashboard
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
## Sections
### Smart Sections
Smart sections are automatically generated based on your reading activity:
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
- **In Progress**: Books you've started (progress > 0%)
- **Recently Added**: Newest items added to this library
- **Recently Read**: Books you've completed (100% progress)
- **Not Started**: Books you haven't read yet
### User Collections
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
To enable a collection:
1. Go to Collections
2. Edit a collection
3. Toggle "Show on Dashboard"
4. Save
### Customizing Your Dashboard
1. Click the ⚙️ (gear icon) in the top-right
2. **Drag sections** to reorder them
3. **Toggle visibility** with the switches
4. **Adjust items per section** (10-50 items)
5. Click "Save Changes"
Settings are saved per library.
### Library Switching
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
### Keyboard Navigation
- **Tab**: Navigate between sections and books
- **Arrow Keys**: Scroll carousels horizontally
- **Enter**: Open selected book
### Touch Gestures (Mobile)
- **Swipe**: Drag carousel left/right to scroll
- **Tap**: Open book details
```
---
### **Phase 13: Bruno Tests** (Already Created ✅)
**COMPLIANCE**: Bruno tests already exist in `bruno/dashboard/`
**Existing Test Files**:
-`get-dashboard-sections.yml` - Test GET /api/dashboard/sections
-`get-sections-by-library.yml` - Test with library_id parameter
-`update-preferences.yml` - Test POST /settings (dashboard preferences)
-`create-collection-with-dashboard.yml` - Test collection creation with dashboard visibility
-`update-collection-visibility.yml` - Test toggling show_on_dashboard
**Coverage**:
- ✅ Three-context testing (no user, user, admin) - handled by Bruno auth inherit
- ✅ Section order customization
- ✅ Hidden sections filtering
- ✅ Collections with dashboard visibility
- ✅ Limit parameter validation
- ✅ Error cases (missing library_id, invalid UUID)
**To Run Tests**:
```bash
# Install Bruno CLI
npm install -g @usebruno/cli
# Run dashboard tests
bru run bruno/dashboard/ --env local
```
**No additional Bruno tests needed** - existing coverage is comprehensive.
---
## Summary: Key Changes from Original Carousel Dashboard Plan
### ✅ **What's Unchanged** (Phases 1-3, 7-11):
- ✅ Database schema changes
- ✅ Service layer implementation (with user preferences support)
- ✅ Database queries
- ✅ Template types (templates/types.go)
- ✅ Settings template structure
- ✅ SSR approach (frontend.go)
- ✅ HTMX for library switching
- ✅ TypeScript implementation
### 🔧 **What's Changed** (Phases 4-6):
**1. Template HTML:**
- **Before:** `<button onclick="scrollCarousel('{ section.ID }', -1)">`
- **After:** `<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">`
**4. TypeScript Implementation:**
**1. Architecture - API Handler Separation:**
- **Before:** No JSON API endpoint, SSR only
- **After:** Generic `/api/dashboard/sections` endpoint (JSON) for reuse
- **Files:** `internal/handlers/dashboard.go`, `internal/router/dashboard.go`
- **Benefit:** Single source of truth for web UI, mobile apps, plugins
**2. Service Layer - User Preferences:**
- **Before:** Service ignored user's section order and hidden sections
- **After:** Service accepts `sectionOrder` and `hiddenSections` parameters
- **Benefit:** Customized dashboards for all clients (web, mobile, plugins)
**3. Template HTML:****
```typescript
// ❌ Old pattern
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;
```
**After (event delegation):**
```typescript
// ✅ New pattern (uses shared on() utility)
on('click', '[data-action="scroll-carousel"]', (target) => {
scrollCarousel(target.dataset.sectionId, parseInt(target.dataset.direction));
});
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
```
**5. API Calls:****
- **Before:** Raw `fetch('/settings', { method: 'POST', ... })`
- **After:** `await apiClient.post('/settings', data)`
**6. Type Definitions:****
- **Before:** Inline types defined in each file
- **After:** Import shared types or define in `web/ts/features/dashboard/types.ts` matching Go handlers
**7. Error Handling:****
- **Before:** `if (typeof showToast === 'function') { showToast(...) }`
- **After:** `import { showToast } from '../../core/toast.js'` and use `showToast.success()`, `showToast.error()`
---
## Execution Timeline
### **Recommended Order: TypeScript First**
| Phase | Duration | Dependencies | Deliverables |
|-------|----------|--------------|--------------|
| **TypeScript Conversion** | 16-21 days | None | All inline JS → TypeScript modules |
| **Dashboard Phase 1-3** | 6-9 hours | None | Backend (DB, service, queries) |
| **Dashboard Phase 4-6** | 2-3 hours | Dashboard 1-3 | Handler, router, frontend routes |
| **Dashboard Phase 7-9** | 6-7 hours | TypeScript Conversion | Templates + Settings |
| **Dashboard Phase 10-11** | 2-3 hours | TypeScript Conversion + Dashboard 1-9 | TypeScript modules |
| **Dashboard Phase 12-13** | 1-2 hours | Dashboard 1-11 | Documentation + Bruno tests |
| **Total** | **19-25 days** | | Complete TypeScript + Carousel Dashboard |
---
## Success Criteria
### Backend (Phases 1-3):
- [ ] Database schema updated and recreated
- [ ] Service layer implemented
- ] Database queries working
- [ ] Routes rendering SSR with data
- [ ] Templates types defined
- [ ] Bruno tests passing
### API (Phases 4-6):
- [ ] Handler file created (`internal/handlers/dashboard.go`)
- [ ] Router file created (`internal/router/dashboard.go`)
- [ ] `/api/dashboard/sections` endpoint working
- [ ] User preferences applied (order, hidden sections)
- [ ] Collections with `show_on_dashboard` included
- [ ] JSON response matches documentation
- [ ] Bruno tests passing
### Frontend (Phases 7-9):
- [ ] Carousel scroll works (mouse and touch)
- [ ] Dashboard settings modal opens/closes
- [ ] Settings form saves correctly
- [ ] Theme switching works
- [ ] Drag-and-drop for section reordering
- [ ] Library switching via HTMX
- [ ] Book detail page displays correctly
### Documentation & Testing (Phase 12-13):
- [ ] API documentation created (`docs/developer/api/dashboard.md`)
- [ ] User documentation created (`docs/user/dashboard.md`)
- [ ] Bruno tests passing (all 5 existing tests)
- [ ] Three-context testing verified
- [ ] Documentation matches implementation
---
*Updated: 2025-02-17*
*Prerequisites: TypeScript Conversion Plan must be completed first*
*Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md*
*Architecture: Hybrid SSR + Generic API with Single Source of Truth*
*Key Changes: Added `/api/dashboard/sections` JSON endpoint, user preferences applied in service layer*