Fixed integration tests to use existing helpers from test_helpers.go: Changes: - Replace getUserUUIDFromToken() with getTestUserID(t, db) helper ✅ - Replace parseUUID() with uuid.MustParse() ✅ - Add explicit comments about automatic cleanup via t.Cleanup() ✅ Test Helpers Used (all from test_helpers.go): - setupTestServer(t) - creates test server with automatic cleanup - loginTestUser(t, ts, db) - logs in admin user - loginRegularUser(t, ts, db) - logs in regular user - setupDeviceTest(t) - creates server + user + device + library - getTestUserID(t, db) - gets/creates admin test user UUID - uuid.MustParse() - parses UUID strings Cleanup Pattern: - Automatic via t.Cleanup() inside setupTestServer() - Registered automatically when setupTestServer() is called - No manual defer setup.Close() needed - Runs even if test fails or panics - Cleanup order: queue → connections → server → database Dashboard-Specific Helper: - updateDashboardPreferences() - only for dashboard testing - Saves dashboard preferences for test scenarios Benefits: - Uses proven, existing helpers (no reinventing the wheel) - Automatic cleanup prevents resource leaks - Follows project testing patterns exactly - Less custom code = fewer bugs
2664 lines
96 KiB
Markdown
2664 lines
96 KiB
Markdown
# 🎬 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.
|
||
|
||
---
|
||
|
||
### **Phase 14: Unit Tests** (2-3 hours)
|
||
|
||
**COMPLIANCE**: Unit tests alongside source files, following project patterns
|
||
|
||
#### 14.1 Service Layer Unit Tests
|
||
**File: `internal/services/dashboard_service_test.go`** (new file)
|
||
|
||
```go
|
||
package services
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"bookhoard/internal/database"
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestFilterHiddenSections(t *testing.T) {
|
||
service := &DashboardService{}
|
||
|
||
items := []SectionItems{
|
||
{SectionKey: "continue-reading", Items: nil},
|
||
{SectionKey: "in-progress", Items: nil},
|
||
{SectionKey: "recently-added", Items: nil},
|
||
}
|
||
|
||
t.Run("No hidden sections", func(t *testing.T) {
|
||
result := service.filterHiddenSections(items, []string{})
|
||
assert.Equal(t, 3, len(result))
|
||
})
|
||
|
||
t.Run("Hide one section", func(t *testing.T) {
|
||
result := service.filterHiddenSections(items, []string{"in-progress"})
|
||
assert.Equal(t, 2, len(result))
|
||
assert.Equal(t, "continue-reading", result[0].SectionKey)
|
||
assert.Equal(t, "recently-added", result[1].SectionKey)
|
||
})
|
||
|
||
t.Run("Hide multiple sections", func(t *testing.T) {
|
||
result := service.filterHiddenSections(items, []string{"continue-reading", "recently-added"})
|
||
assert.Equal(t, 1, len(result))
|
||
assert.Equal(t, "in-progress", result[0].SectionKey)
|
||
})
|
||
}
|
||
|
||
func TestReorderSections(t *testing.T) {
|
||
service := &DashboardService{}
|
||
|
||
items := []SectionItems{
|
||
{SectionKey: "continue-reading", Items: nil},
|
||
{SectionKey: "in-progress", Items: nil},
|
||
{SectionKey: "recently-added", Items: nil},
|
||
}
|
||
|
||
t.Run("No custom order", func(t *testing.T) {
|
||
result := service.reorderSections(items, []string{})
|
||
assert.Equal(t, 3, len(result))
|
||
assert.Equal(t, "continue-reading", result[0].SectionKey)
|
||
})
|
||
|
||
t.Run("Custom order - all sections", func(t *testing.T) {
|
||
customOrder := []string{"recently-added", "continue-reading", "in-progress"}
|
||
result := service.reorderSections(items, customOrder)
|
||
assert.Equal(t, 3, len(result))
|
||
assert.Equal(t, "recently-added", result[0].SectionKey)
|
||
assert.Equal(t, "continue-reading", result[1].SectionKey)
|
||
assert.Equal(t, "in-progress", result[2].SectionKey)
|
||
})
|
||
|
||
t.Run("Custom order - partial (new sections appended)", func(t *testing.T) {
|
||
customOrder := []string{"in-progress", "continue-reading"}
|
||
result := service.reorderSections(items, customOrder)
|
||
assert.Equal(t, 3, len(result))
|
||
assert.Equal(t, "in-progress", result[0].SectionKey)
|
||
assert.Equal(t, "continue-reading", result[1].SectionKey)
|
||
assert.Equal(t, "recently-added", result[2].SectionKey) // Appended at end
|
||
})
|
||
|
||
t.Run("Custom order - unknown section ignored", func(t *testing.T) {
|
||
customOrder := []string{"unknown-section", "continue-reading"}
|
||
result := service.reorderSections(items, customOrder)
|
||
assert.Equal(t, 3, len(result))
|
||
assert.Equal(t, "continue-reading", result[0].SectionKey)
|
||
})
|
||
}
|
||
|
||
func TestGetDashboardPreferences(t *testing.T) {
|
||
// This would require a test database setup
|
||
// For now, test with mock or skip
|
||
t.Skip("Requires database integration - use integration tests")
|
||
}
|
||
```
|
||
|
||
**Key Points**:
|
||
- ✅ Unit tests alongside source file (`dashboard_service_test.go`)
|
||
- ✅ Test pure functions (filterHiddenSections, reorderSections)
|
||
- ✅ Table-driven tests for multiple scenarios
|
||
- ✅ Use testify/assert for assertions
|
||
- ✅ Skip database-dependent tests (use integration tests)
|
||
|
||
#### 14.2 Handler Unit Tests
|
||
**File: `internal/handlers/dashboard_test.go`** (new file)
|
||
|
||
```go
|
||
package handlers
|
||
|
||
import (
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
)
|
||
|
||
func TestGetSectionType(t *testing.T) {
|
||
t.Run("Smart sections", func(t *testing.T) {
|
||
smartSections := []string{
|
||
"continue-reading", "in-progress", "recently-added",
|
||
"recently-read", "unread",
|
||
}
|
||
for _, key := range smartSections {
|
||
result := getSectionType(key)
|
||
assert.Equal(t, "smart", result, "Section %s should be smart", key)
|
||
}
|
||
})
|
||
|
||
t.Run("Collection sections", func(t *testing.T) {
|
||
result := getSectionType("collection-uuid-123")
|
||
assert.Equal(t, "collection", result)
|
||
})
|
||
}
|
||
|
||
func TestGetSectionTitle(t *testing.T) {
|
||
tests := []struct {
|
||
key string
|
||
expected string
|
||
}{
|
||
{"continue-reading", "Continue Reading"},
|
||
{"in-progress", "In Progress"},
|
||
{"recently-added", "Recently Added"},
|
||
{"recently-read", "Recently Read"},
|
||
{"unread", "Not Started"},
|
||
{"my-custom-collection", "my-custom-collection"},
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.key, func(t *testing.T) {
|
||
result := getSectionTitle(tt.key)
|
||
assert.Equal(t, tt.expected, result)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestGetSectionIcon(t *testing.T) {
|
||
tests := []struct {
|
||
key string
|
||
expected string
|
||
}{
|
||
{"continue-reading", "📖"},
|
||
{"in-progress", "📚"},
|
||
{"recently-added", "🆕"},
|
||
{"recently-read", "✅"},
|
||
{"unread", "📕"},
|
||
{"unknown", "📚"}, // Default
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.key, func(t *testing.T) {
|
||
result := getSectionIcon(tt.key)
|
||
assert.Equal(t, tt.expected, result)
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestGetSectionViewAllURL(t *testing.T) {
|
||
tests := []struct {
|
||
key string
|
||
expected string
|
||
}{
|
||
{"continue-reading", "/section/continue-reading"},
|
||
{"in-progress", "/section/in-progress"},
|
||
{"recently-added", "/section/recently-added"},
|
||
{"recently-read", "/history"},
|
||
{"unread", "/section/unread"},
|
||
{"my-collection", ""}, // Collections don't have view-all
|
||
}
|
||
|
||
for _, tt := range tests {
|
||
t.Run(tt.key, func(t *testing.T) {
|
||
result := getSectionViewAllURL(tt.key)
|
||
assert.Equal(t, tt.expected, result)
|
||
})
|
||
}
|
||
}
|
||
```
|
||
|
||
**Key Points**:
|
||
- ✅ Unit tests alongside handler file (`dashboard_test.go`)
|
||
- ✅ Test pure helper functions (getSectionType, getSectionTitle, etc.)
|
||
- ✅ Table-driven tests for multiple scenarios
|
||
- ✅ No HTTP requests (use integration tests)
|
||
|
||
---
|
||
|
||
### **Phase 15: Integration Tests** (2-3 hours)
|
||
|
||
**COMPLIANCE**: Integration tests in `cmd/server/tests/`, using `setupTestServer` helper
|
||
|
||
**Available Test Helpers (from `test_helpers.go`):**
|
||
|
||
| Helper | Purpose | Returns |
|
||
|--------|---------|---------|
|
||
| `setupTestServer(t)` | Creates test server with auto cleanup | `*TestServerSetup` |
|
||
| `loginTestUser(t, ts, db)` | Logs in admin user (role: admin) | JWT token string |
|
||
| `loginRegularUser(t, ts, db)` | Logs in regular user (role: user) | JWT token string |
|
||
| `setupDeviceTest(t)` | Creates server + user + device + library | `*TestDeviceSetup` |
|
||
| `getTestUserID(t, db)` | Gets/creates admin test user | `uuid.UUID` |
|
||
| `getRegularUserID(t, db)` | Gets/creates regular test user | `uuid.UUID` |
|
||
|
||
**Cleanup Pattern:**
|
||
- `setupTestServer()` automatically registers `t.Cleanup()`
|
||
- Cleanup runs even if test fails or panics
|
||
- No manual `defer setup.Close()` needed
|
||
|
||
**TestServerSetup Contains:**
|
||
```go
|
||
type TestServerSetup struct {
|
||
Server *httptest.Server // Test HTTP server
|
||
DB *database.Queries // Database queries
|
||
DBPool *pgxpool.Pool // Database pool
|
||
Config *config.Config // Test configuration
|
||
ConnManager *wsync.ConnectionManager
|
||
QueueProcessor *wsync.SyncQueueProcessor
|
||
// ... auto cleanup via t.Cleanup()
|
||
}
|
||
```
|
||
|
||
**File: `cmd/server/tests/dashboard_test.go`** (new file)
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"bookhoard/internal/database"
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgtype"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
func TestDashboardAPI_GetSections(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
// Note: t.Cleanup() is automatically registered inside setupTestServer()
|
||
// No manual cleanup needed - Close() called automatically when test completes
|
||
|
||
adminToken := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
// Create test library with media items
|
||
deviceSetup := setupDeviceTest(t)
|
||
libraryID := deviceSetup.CreateLibrary(t, "Test Ebooks Library", "ebooks")
|
||
|
||
t.Run("GetSections_AsAdmin", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
sections, exists := result["sections"]
|
||
assert.True(t, exists, "Response should contain sections")
|
||
assert.NotNil(t, sections)
|
||
|
||
// Verify section structure
|
||
sectionsArray := sections.([]interface{})
|
||
assert.Greater(t, len(sectionsArray), 0, "Should have at least one section")
|
||
|
||
// Verify smart sections exist
|
||
sectionKeys := make(map[string]bool)
|
||
for _, s := range sectionsArray {
|
||
section := s.(map[string]interface{})
|
||
key := section["id"].(string)
|
||
sectionKeys[key] = true
|
||
|
||
// Verify structure
|
||
assert.Contains(t, section, "type")
|
||
assert.Contains(t, section, "title")
|
||
assert.Contains(t, section, "icon")
|
||
assert.Contains(t, section, "items")
|
||
}
|
||
|
||
// Check for expected smart sections
|
||
assert.True(t, sectionKeys["continue-reading"] || sectionKeys["recently-added"],
|
||
"Should have at least one smart section")
|
||
})
|
||
|
||
t.Run("GetSections_WithoutAuth", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
|
||
// No authorization header
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
})
|
||
|
||
t.Run("GetSections_MissingLibraryID", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||
})
|
||
|
||
t.Run("GetSections_InvalidLibraryID", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id=invalid-uuid", nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||
})
|
||
|
||
t.Run("GetSections_WithLimit", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID+"&limit=10", nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
sections := result["sections"].([]interface{})
|
||
for _, s := range sections {
|
||
section := s.(map[string]interface{})
|
||
items := section["items"].([]interface{})
|
||
assert.LessOrEqual(t, len(items), 10, "Should respect limit parameter")
|
||
}
|
||
})
|
||
|
||
t.Run("GetSections_WithRegularUser", func(t *testing.T) {
|
||
// Get regular user token
|
||
regularToken := loginRegularUser(t, setup.Server, setup.DB)
|
||
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+regularToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
})
|
||
}
|
||
|
||
func TestDashboardAPI_UserPreferences(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
// Automatic cleanup via t.Cleanup() - no manual cleanup needed
|
||
|
||
adminToken := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
// Create test library
|
||
deviceSetup := setupDeviceTest(t)
|
||
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
|
||
|
||
t.Run("GetSections_WithHiddenSections", func(t *testing.T) {
|
||
// Get admin user UUID using existing helper
|
||
userUUID := getTestUserID(t, setup.DB)
|
||
libUUID := uuid.MustParse(libraryID)
|
||
|
||
// Save dashboard preferences with hidden sections
|
||
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
|
||
"hidden_sections": []string{"recently-added"},
|
||
})
|
||
|
||
// Now get sections - "recently-added" should be hidden
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
sections := result["sections"].([]interface{})
|
||
|
||
// Verify "recently-added" is not in response
|
||
for _, s := range sections {
|
||
section := s.(map[string]interface{})
|
||
sectionID := section["id"].(string)
|
||
assert.NotEqual(t, "recently-added", sectionID, "Recently added should be hidden")
|
||
}
|
||
})
|
||
|
||
t.Run("GetSections_WithCustomOrder", func(t *testing.T) {
|
||
userUUID := getTestUserID(t, setup.DB)
|
||
libUUID := uuid.MustParse(libraryID)
|
||
|
||
// Save dashboard preferences with custom order
|
||
customOrder := []string{"recently-read", "continue-reading", "in-progress"}
|
||
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
|
||
"section_order": customOrder,
|
||
})
|
||
|
||
// Get sections - should return in custom order
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
sections := result["sections"].([]interface{})
|
||
|
||
// Verify order matches custom order (for sections that exist)
|
||
sectionOrder := make([]string, 0)
|
||
for _, s := range sections {
|
||
section := s.(map[string]interface{})
|
||
sectionID := section["id"].(string)
|
||
sectionOrder = append(sectionOrder, sectionID)
|
||
}
|
||
|
||
// First section should be "recently-read" if it exists
|
||
if len(sectionOrder) > 0 {
|
||
assert.Equal(t, "recently-read", sectionOrder[0])
|
||
}
|
||
})
|
||
}
|
||
|
||
func TestDashboardSSR_Page(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
// Automatic cleanup via t.Cleanup() - no manual cleanup needed
|
||
|
||
adminToken := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
// Create test library
|
||
deviceSetup := setupDeviceTest(t)
|
||
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
|
||
|
||
t.Run("GetDashboardPage_AsAdmin", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
|
||
req.Header.Set("Authorization", "Bearer "+adminToken)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
assert.Contains(t, resp.Header.Get("Content-Type"), "text/html")
|
||
|
||
// Verify HTML contains dashboard elements
|
||
body := new(bytes.Buffer)
|
||
body.ReadFrom(resp.Body)
|
||
html := body.String()
|
||
|
||
assert.Contains(t, html, "dashboard-section")
|
||
assert.Contains(t, html, "carousel-track")
|
||
})
|
||
|
||
t.Run("GetDashboardPage_WithoutAuth", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/dashboard?library_id="+libraryID, nil)
|
||
// No authorization header
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
})
|
||
}
|
||
|
||
// Helper functions for dashboard tests
|
||
|
||
// updateDashboardPreferences saves dashboard preferences for testing
|
||
// NOTE: This is specific to dashboard testing - not in test_helpers.go
|
||
func updateDashboardPreferences(t *testing.T, db *database.Queries, userID, libraryID uuid.UUID, prefs map[string]interface{}) {
|
||
hiddenSections := prefs["hidden_sections"].([]string)
|
||
sectionOrder := prefs["section_order"].([]string)
|
||
|
||
_, err := db.UpsertDashboardPreferences(context.Background(), database.UpsertDashboardPreferencesParams{
|
||
UserID: pgtype.UUID{Bytes: userID, Valid: true},
|
||
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
|
||
HiddenSections: hiddenSections,
|
||
SectionOrder: sectionOrder,
|
||
ItemsPerSection: pgtype.Int4{Int32: 20, Valid: true},
|
||
})
|
||
require.NoError(t, err, "Failed to update dashboard preferences")
|
||
}
|
||
```
|
||
|
||
**Key Helper Functions Available (from test_helpers.go):**
|
||
|
||
```go
|
||
// setupTestServer creates complete test environment with auto cleanup
|
||
setup := setupTestServer(t)
|
||
// No manual cleanup needed - t.Cleanup() registered automatically
|
||
|
||
// loginTestUser - logs in admin user (testuser@example.com)
|
||
adminToken := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
// loginRegularUser - logs in regular user (testregularuser@example.com)
|
||
userToken := loginRegularUser(t, setup.Server, setup.DB)
|
||
|
||
// setupDeviceTest - creates server + user + device + library
|
||
deviceSetup := setupDeviceTest(t)
|
||
deviceSetup.CreateLibrary(t, "My Library", "ebooks")
|
||
deviceSetup.CreateDevice(t, "Kindle", "kindle", "kindle-123")
|
||
|
||
// getTestUserID - gets/creates admin test user UUID
|
||
adminUUID := getTestUserID(t, setup.DB)
|
||
|
||
// getRegularUserID - gets/creates regular test user UUID
|
||
userUUID := getRegularUserID(t, setup.DB)
|
||
|
||
// uuid.MustParse - parse UUID string (from uuid package)
|
||
libUUID := uuid.MustParse(libraryID)
|
||
```
|
||
|
||
**Dashboard-Specific Helper (created for these tests):**
|
||
|
||
```go
|
||
// updateDashboardPreferences - saves dashboard preferences for testing
|
||
// NOTE: Only needed for dashboard testing, not a general helper
|
||
updateDashboardPreferences(t, setup.DB, userUUID, libUUID, map[string]interface{}{
|
||
"hidden_sections": []string{"recently-added"},
|
||
"section_order": []string{"recently-read", "continue-reading"},
|
||
})
|
||
```
|
||
|
||
**Cleanup Pattern:**
|
||
- ✅ Automatic via `t.Cleanup()` in `setupTestServer()`
|
||
- ✅ Runs even if test fails or panics
|
||
- ✅ No manual `defer setup.Close()` needed
|
||
- ✅ Cleans up: queue processor → connection manager → HTTP server → database pool
|
||
|
||
**Key Points**:
|
||
- ✅ Integration tests in `cmd/server/tests/`
|
||
- ✅ Uses `setupTestServer(t)` helper (from `test_helpers.go`)
|
||
- ✅ Three-context testing (no auth, user, admin)
|
||
- ✅ Tests both JSON API (`/api/dashboard/sections`) and SSR (`/dashboard`)
|
||
- ✅ Tests user preferences (hidden sections, custom order)
|
||
- ✅ Follows existing test patterns (see `auth_test.go`, `collections_bulk_test.go`)
|
||
- ✅ Uses `require.NoError` for setup, `assert.Equal` for verification
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| **Dashboard Phase 14-15** | 4-6 hours | Dashboard 1-13 | Unit tests + Integration tests |
|
||
| **Total** | **23-31 days** | | Complete TypeScript + Carousel Dashboard + Tests |
|
||
|
||
---
|
||
|
||
## 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
|
||
|
||
### Unit Tests (Phase 14):
|
||
- [ ] Service layer tests (`internal/services/dashboard_service_test.go`)
|
||
- [ ] Handler helper tests (`internal/handlers/dashboard_test.go`)
|
||
- [ ] filterHiddenSections() tested
|
||
- [ ] reorderSections() tested
|
||
- [ ] getSectionType() tested
|
||
- [ ] getSectionTitle() tested
|
||
- [ ] getSectionIcon() tested
|
||
- [ ] All unit tests passing (`go test ./internal/services/... ./internal/handlers/...`)
|
||
|
||
### Integration Tests (Phase 15):
|
||
- [ ] Integration tests created (`cmd/server/tests/dashboard_test.go`)
|
||
- [ ] Uses `setupTestServer(t)` helper with automatic `t.Cleanup()` ✅
|
||
- [ ] Uses `loginTestUser(t, ts, db)` helper ✅
|
||
- [ ] Uses `loginRegularUser(t, ts, db)` helper ✅
|
||
- [ ] Uses `setupDeviceTest(t)` helper ✅
|
||
- [ ] Uses `getTestUserID(t, db)` helper ✅
|
||
- [ ] Uses `uuid.MustParse()` for UUID parsing ✅
|
||
- [ ] Automatic cleanup (no manual defer/Close needed) ✅
|
||
- [ ] Three-context testing (no auth, user, admin)
|
||
- [ ] GET /api/dashboard/sections tested
|
||
- [ ] User preferences tested (hidden sections, custom order)
|
||
- [ ] SSR /dashboard tested
|
||
- [ ] Error cases tested (missing library_id, invalid UUID)
|
||
- [ ] All integration tests passing (`go test ./cmd/server/tests/...`)
|
||
|
||
---
|
||
|
||
*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*
|
||
*Testing: Unit tests alongside files, Integration tests in cmd/server/tests/ with setupTestServer helper*
|