docs(dashboard): update Carousel plan with API endpoint and user preferences
Major architectural improvements: 1. Add generic /api/dashboard/sections JSON endpoint - Created internal/handlers/dashboard.go (new file) - Created internal/router/dashboard.go (new file) - Single source of truth for web UI, mobile apps, plugins - Follows existing handler/router pattern 2. Update DashboardService to apply user preferences - GetSectionItems() now accepts sectionOrder and hiddenSections - filterHiddenSections() removes user's hidden sections - reorderSections() applies user's custom order - Ensures consistent behavior across all clients 3. Separate concerns properly - API handlers in internal/handlers/dashboard.go - SSR routes remain in internal/router/frontend.go - Both use same DashboardService (single source of truth) 4. Reorganize implementation phases - Phase 1-3: Database, service, queries - Phase 4-6: Handler, router, frontend routes - Phase 7-9: Templates and settings - Phase 10-11: TypeScript modules - Phase 12-13: Documentation and testing 5. Add documentation - docs/developer/api/dashboard.md (API reference) - docs/user/dashboard.md (user guide) 6. Bruno tests already exist - bruno/dashboard/ has 5 comprehensive test files - Three-context testing (no user, user, admin) - No additional tests needed Benefits: - Uniform dashboard across web, mobile, plugins - Single source of truth (no duplicate logic) - User preferences respected by all clients - Follows established project patterns - Comprehensive test coverage Timeline: Updated to reflect 13 phases (19-25 days total with TypeScript)
This commit is contained in:
+539
-38
@@ -180,8 +180,15 @@ type SectionItems struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetSectionItems fetches raw items for each section type
|
// GetSectionItems fetches raw items for each section type
|
||||||
|
// Accepts user preferences to customize order and visibility
|
||||||
// Handler will format these into template.SectionData
|
// Handler will format these into template.SectionData
|
||||||
func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
|
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
|
var results []SectionItems
|
||||||
|
|
||||||
// 1. Continue Reading - items with progress > 0 and < 1
|
// 1. Continue Reading - items with progress > 0 and < 1
|
||||||
@@ -208,9 +215,69 @@ func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryI
|
|||||||
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
|
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
|
||||||
results = append(results, collectionItems...)
|
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
|
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) {
|
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
|
// Query media items WHERE progress > 0 AND progress < 1
|
||||||
// Ordered by last_read_at DESC
|
// Ordered by last_read_at DESC
|
||||||
@@ -303,11 +370,234 @@ Regenerate: `cd internal/database && sqlc generate`
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Phase 4: Routes** (1-2 hours)
|
### **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)
|
**File: `internal/router/frontend.go`** (MODIFY existing file)
|
||||||
|
|
||||||
**COMPLIANCE**: Add inline routes following existing pattern
|
**COMPLIANCE**: SSR routes stay in frontend.go, use same service layer
|
||||||
|
|
||||||
**Modify existing `/dashboard` route** (around line 105):
|
**Modify existing `/dashboard` route** (around line 105):
|
||||||
```go
|
```go
|
||||||
@@ -329,10 +619,21 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get sections from service
|
|
||||||
libUUID, _ := uuid.Parse(libraryID)
|
libUUID, _ := uuid.Parse(libraryID)
|
||||||
userUUID, _ := uuid.Parse(user.ID)
|
userUUID, _ := uuid.Parse(user.ID)
|
||||||
sectionItems, err := cfg.DashboardService.GetSectionItems(c.Request().Context(), userUUID, libUUID, 20)
|
|
||||||
|
// 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 {
|
if err != nil {
|
||||||
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
|
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
|
||||||
}
|
}
|
||||||
@@ -355,8 +656,8 @@ frontendProtected.GET("/dashboard", func(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build sections
|
// Build sections (converts service items to template types)
|
||||||
sections := buildSections(sectionItems, database.UserDashboardPreferences{})
|
sections := buildSections(sectionItems, prefs)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
|
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
|
||||||
@@ -465,7 +766,7 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Note**: No separate handler file needed. Following existing pattern, routes are inline in `frontend.go` and call service methods directly.
|
**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`**:
|
**Add helper function to `internal/router/frontend.go`**:
|
||||||
```go
|
```go
|
||||||
@@ -540,7 +841,7 @@ func buildSections(items []services.SectionItems, prefs database.UserDashboardPr
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Phase 5: Template Types** (30 min)
|
### **Phase 7: Template Types** (30 min)
|
||||||
|
|
||||||
**File: `templates/types.go`** (ADD to existing file)
|
**File: `templates/types.go`** (ADD to existing file)
|
||||||
|
|
||||||
@@ -571,7 +872,7 @@ type BookCardData struct {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Phase 5: Settings Template** (2 hours)
|
### **Phase 8: Settings Template** (2 hours)
|
||||||
|
|
||||||
**COMPLIANCE**: Use template types, TailwindSSR, SSR
|
**COMPLIANCE**: Use template types, TailwindSSR, SSR
|
||||||
|
|
||||||
@@ -700,7 +1001,7 @@ templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboar
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Phase 6: Templates** (4-5 hours)
|
### **Phase 9: Templates** (4-5 hours)
|
||||||
|
|
||||||
**COMPLIANCE**:
|
**COMPLIANCE**:
|
||||||
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
|
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
|
||||||
@@ -996,7 +1297,7 @@ templ DashboardSectionsPartial(sections []SectionData) {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Phase 7: TypeScript** (REVISED - 2-3 hours)
|
### **Phase 10: TypeScript** (REVISED - 2-3 hours)
|
||||||
|
|
||||||
**COMPLIANCE** (Post-TypeScript Conversion):
|
**COMPLIANCE** (Post-TypeScript Conversion):
|
||||||
- ✅ TypeScript files in `web/ts/features/dashboard/` (not `web/src/`)
|
- ✅ TypeScript files in `web/ts/features/dashboard/` (not `web/src/`)
|
||||||
@@ -1322,7 +1623,7 @@ on('click', '[data-action="cancel"]', () => {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### **Phase 8: Book Detail Page** (3-4 hours)
|
### **Phase 11: Book Detail Page** (3-4 hours)
|
||||||
|
|
||||||
**File: `templates/book_detail.templ`** (new file)
|
**File: `templates/book_detail.templ`** (new file)
|
||||||
|
|
||||||
@@ -1449,32 +1750,221 @@ templ BookDetail(user User, book database.MediaItems, progress database.ReadingP
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### **Phase 12: Documentation** (1-2 hours)
|
||||||
|
|
||||||
|
**COMPLIANCE**: Update API documentation for new endpoint
|
||||||
|
|
||||||
|
#### 12.1 API Documentation
|
||||||
|
**File: `docs/developer/api/dashboard.md`** (new file)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Dashboard API
|
||||||
|
|
||||||
|
## Get Dashboard Sections
|
||||||
|
|
||||||
|
Retrieve all dashboard sections for a specific library, including smart sections and user collections.
|
||||||
|
|
||||||
|
**Endpoint**: `GET /api/dashboard/sections`
|
||||||
|
|
||||||
|
**Authentication**: Required (Bearer token)
|
||||||
|
|
||||||
|
### Query Parameters
|
||||||
|
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|--------|----------|-----------------------------------------------|
|
||||||
|
| library_id| string | Yes | Library UUID to fetch sections for |
|
||||||
|
| limit | number | No | Items per section (default: 20, max: 100) |
|
||||||
|
|
||||||
|
### Response
|
||||||
|
|
||||||
|
Returns array of sections in user's customized order (respects `section_order` and `hidden_sections` preferences).
|
||||||
|
|
||||||
|
**Section Types**:
|
||||||
|
- `smart`: Auto-generated sections based on reading activity
|
||||||
|
- `collection`: User-created collections with `show_on_dashboard: true`
|
||||||
|
|
||||||
|
**Smart Sections**:
|
||||||
|
| ID | Title | Icon | Description |
|
||||||
|
|-----------------|------------------|------|--------------------------------------------------|
|
||||||
|
| continue-reading| Continue Reading | 📖 | Books with progress > 0% and < 100% |
|
||||||
|
| in-progress | In Progress | 📚 | Books with progress > 0% |
|
||||||
|
| recently-added | Recently Added | 🆕 | Newest items in library |
|
||||||
|
| recently-read | Recently Read | ✅ | Books with progress = 100% |
|
||||||
|
| unread | Not Started | 📕 | Books with no reading progress |
|
||||||
|
|
||||||
|
### Example Response
|
||||||
|
|
||||||
|
\`\`\`json
|
||||||
|
{
|
||||||
|
"sections": [
|
||||||
|
{
|
||||||
|
"id": "continue-reading",
|
||||||
|
"type": "smart",
|
||||||
|
"title": "Continue Reading",
|
||||||
|
"icon": "📖",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "uuid-here",
|
||||||
|
"title": "Book Title",
|
||||||
|
"author": "Author Name",
|
||||||
|
"cover_image_path": "/path/to/cover.jpg"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"view_all_url": "/section/continue-reading"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "collection-uuid",
|
||||||
|
"type": "collection",
|
||||||
|
"title": "My Favorites",
|
||||||
|
"icon": "⭐",
|
||||||
|
"items": [...],
|
||||||
|
"view_all_url": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### User Preferences
|
||||||
|
|
||||||
|
The endpoint respects user's dashboard preferences:
|
||||||
|
|
||||||
|
- **`section_order`**: Sections returned in user's custom order
|
||||||
|
- **`hidden_sections`**: Hidden sections excluded from response
|
||||||
|
- **`items_per_section`**: Default limit from user preferences (overridden by `?limit=` query param)
|
||||||
|
|
||||||
|
### Error Responses
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|------------------------|
|
||||||
|
| 400 | Missing library_id |
|
||||||
|
| 400 | Invalid library_id |
|
||||||
|
| 401 | Unauthorized |
|
||||||
|
| 500 | Failed to load sections |
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 12.2 User Documentation
|
||||||
|
**File: `docs/user/dashboard.md`** (new file)
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Dashboard
|
||||||
|
|
||||||
|
The Bookhoard dashboard provides a Carousel-style horizontal carousel interface for browsing your book library.
|
||||||
|
|
||||||
|
## Sections
|
||||||
|
|
||||||
|
### Smart Sections
|
||||||
|
|
||||||
|
Smart sections are automatically generated based on your reading activity:
|
||||||
|
|
||||||
|
- **Continue Reading**: Books you're currently reading (progress between 0-100%)
|
||||||
|
- **In Progress**: Books you've started (progress > 0%)
|
||||||
|
- **Recently Added**: Newest items added to this library
|
||||||
|
- **Recently Read**: Books you've completed (100% progress)
|
||||||
|
- **Not Started**: Books you haven't read yet
|
||||||
|
|
||||||
|
### User Collections
|
||||||
|
|
||||||
|
Any collection marked with "Show on Dashboard" will appear as a section on your dashboard.
|
||||||
|
|
||||||
|
To enable a collection:
|
||||||
|
1. Go to Collections
|
||||||
|
2. Edit a collection
|
||||||
|
3. Toggle "Show on Dashboard"
|
||||||
|
4. Save
|
||||||
|
|
||||||
|
### Customizing Your Dashboard
|
||||||
|
|
||||||
|
1. Click the ⚙️ (gear icon) in the top-right
|
||||||
|
2. **Drag sections** to reorder them
|
||||||
|
3. **Toggle visibility** with the switches
|
||||||
|
4. **Adjust items per section** (10-50 items)
|
||||||
|
5. Click "Save Changes"
|
||||||
|
|
||||||
|
Settings are saved per library.
|
||||||
|
|
||||||
|
### Library Switching
|
||||||
|
|
||||||
|
Use the dropdown in the sticky header to switch between libraries. Each library has its own dashboard settings.
|
||||||
|
|
||||||
|
### Keyboard Navigation
|
||||||
|
|
||||||
|
- **Tab**: Navigate between sections and books
|
||||||
|
- **Arrow Keys**: Scroll carousels horizontally
|
||||||
|
- **Enter**: Open selected book
|
||||||
|
|
||||||
|
### Touch Gestures (Mobile)
|
||||||
|
|
||||||
|
- **Swipe**: Drag carousel left/right to scroll
|
||||||
|
- **Tap**: Open book details
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### **Phase 13: Bruno Tests** (Already Created ✅)
|
||||||
|
|
||||||
|
**COMPLIANCE**: Bruno tests already exist in `bruno/dashboard/`
|
||||||
|
|
||||||
|
**Existing Test Files**:
|
||||||
|
- ✅ `get-dashboard-sections.yml` - Test GET /api/dashboard/sections
|
||||||
|
- ✅ `get-sections-by-library.yml` - Test with library_id parameter
|
||||||
|
- ✅ `update-preferences.yml` - Test POST /settings (dashboard preferences)
|
||||||
|
- ✅ `create-collection-with-dashboard.yml` - Test collection creation with dashboard visibility
|
||||||
|
- ✅ `update-collection-visibility.yml` - Test toggling show_on_dashboard
|
||||||
|
|
||||||
|
**Coverage**:
|
||||||
|
- ✅ Three-context testing (no user, user, admin) - handled by Bruno auth inherit
|
||||||
|
- ✅ Section order customization
|
||||||
|
- ✅ Hidden sections filtering
|
||||||
|
- ✅ Collections with dashboard visibility
|
||||||
|
- ✅ Limit parameter validation
|
||||||
|
- ✅ Error cases (missing library_id, invalid UUID)
|
||||||
|
|
||||||
|
**To Run Tests**:
|
||||||
|
```bash
|
||||||
|
# Install Bruno CLI
|
||||||
|
npm install -g @usebruno/cli
|
||||||
|
|
||||||
|
# Run dashboard tests
|
||||||
|
bru run bruno/dashboard/ --env local
|
||||||
|
```
|
||||||
|
|
||||||
|
**No additional Bruno tests needed** - existing coverage is comprehensive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Summary: Key Changes from Original Carousel Dashboard Plan
|
## Summary: Key Changes from Original Carousel Dashboard Plan
|
||||||
|
|
||||||
### ✅ **What's Unchanged** (Phases 1-5):
|
### ✅ **What's Unchanged** (Phases 1-3, 7-11):
|
||||||
|
|
||||||
- ✅ Database schema changes
|
- ✅ Database schema changes
|
||||||
- ✅ Service layer implementation
|
- ✅ Service layer implementation (with user preferences support)
|
||||||
- ✅ Database queries
|
- ✅ Database queries
|
||||||
- ✅ Routes (frontend.go modifications)
|
|
||||||
- ✅ Template types (templates/types.go)
|
- ✅ Template types (templates/types.go)
|
||||||
- ✅ Settings template structure
|
- ✅ Settings template structure
|
||||||
- ✅ SSR approach
|
- ✅ SSR approach (frontend.go)
|
||||||
- ✅ HTMX for library switching
|
- ✅ HTMX for library switching
|
||||||
|
- ✅ TypeScript implementation
|
||||||
|
|
||||||
### 🔧 **What's Changed** (Phase 6-7):
|
### 🔧 **What's Changed** (Phases 4-6):
|
||||||
|
|
||||||
**1. Template HTML:**
|
**1. Template HTML:**
|
||||||
- **Before:** `<button onclick="scrollCarousel('{ section.ID }', -1)">`
|
- **Before:** `<button onclick="scrollCarousel('{ section.ID }', -1)">`
|
||||||
- **After:** `<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">`
|
- **After:** `<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">`
|
||||||
|
|
||||||
**2. TypeScript Files:**
|
**4. TypeScript Implementation:**
|
||||||
- **Before:** `web/src/carousel.ts`, `web/src/dashboard-settings.ts`
|
|
||||||
- **After:** `web/ts/features/dashboard/carousel.ts`, `web/ts/features/dashboard/settings.ts`
|
|
||||||
|
|
||||||
**3. 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
|
||||||
|
|
||||||
**Before (inline handlers exported to window):**
|
**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
|
```typescript
|
||||||
// ❌ Old pattern
|
// ❌ Old pattern
|
||||||
(window as any).scrollCarousel = scrollCarousel;
|
(window as any).scrollCarousel = scrollCarousel;
|
||||||
@@ -1493,15 +1983,15 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**4. API Calls:**
|
**5. API Calls:****
|
||||||
- **Before:** Raw `fetch('/settings', { method: 'POST', ... })`
|
- **Before:** Raw `fetch('/settings', { method: 'POST', ... })`
|
||||||
- **After:** `await apiClient.post('/settings', data)`
|
- **After:** `await apiClient.post('/settings', data)`
|
||||||
|
|
||||||
**5. Type Definitions:**
|
**6. Type Definitions:****
|
||||||
- **Before:** Inline types defined in each file
|
- **Before:** Inline types defined in each file
|
||||||
- **After:** Import shared types or define in `web/ts/features/dashboard/types.ts` matching Go handlers
|
- **After:** Import shared types or define in `web/ts/features/dashboard/types.ts` matching Go handlers
|
||||||
|
|
||||||
**6. Error Handling:**
|
**7. Error Handling:****
|
||||||
- **Before:** `if (typeof showToast === 'function') { showToast(...) }`
|
- **Before:** `if (typeof showToast === 'function') { showToast(...) }`
|
||||||
- **After:** `import { showToast } from '../../core/toast.js'` and use `showToast.success()`, `showToast.error()`
|
- **After:** `import { showToast } from '../../core/toast.js'` and use `showToast.success()`, `showToast.error()`
|
||||||
|
|
||||||
@@ -1514,8 +2004,11 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
|||||||
| Phase | Duration | Dependencies | Deliverables |
|
| Phase | Duration | Dependencies | Deliverables |
|
||||||
|-------|----------|--------------|--------------|
|
|-------|----------|--------------|--------------|
|
||||||
| **TypeScript Conversion** | 16-21 days | None | All inline JS → TypeScript modules |
|
| **TypeScript Conversion** | 16-21 days | None | All inline JS → TypeScript modules |
|
||||||
| **Dashboard Phase 1-5** | 8-10 hours | None | Backend (DB, service, routes, templates) |
|
| **Dashboard Phase 1-3** | 6-9 hours | None | Backend (DB, service, queries) |
|
||||||
| **Dashboard Phase 6-8** | 2-3 hours | TypeScript Conversion + Dashboard 1-5 | TypeScript + Templates |
|
| **Dashboard Phase 4-6** | 2-3 hours | Dashboard 1-3 | Handler, router, frontend routes |
|
||||||
|
| **Dashboard Phase 7-9** | 6-7 hours | TypeScript Conversion | Templates + Settings |
|
||||||
|
| **Dashboard Phase 10-11** | 2-3 hours | TypeScript Conversion + Dashboard 1-9 | TypeScript modules |
|
||||||
|
| **Dashboard Phase 12-13** | 1-2 hours | Dashboard 1-11 | Documentation + Bruno tests |
|
||||||
| **Total** | **19-25 days** | | Complete TypeScript + Carousel Dashboard |
|
| **Total** | **19-25 days** | | Complete TypeScript + Carousel Dashboard |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1530,16 +2023,16 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
|||||||
- [ ] Templates types defined
|
- [ ] Templates types defined
|
||||||
- [ ] Bruno tests passing
|
- [ ] Bruno tests passing
|
||||||
|
|
||||||
### Frontend (Phases 4-6):
|
### API (Phases 4-6):
|
||||||
- [ ] Templates use `data-action` attributes (no inline onclick)
|
- [ ] Handler file created (`internal/handlers/dashboard.go`)
|
||||||
- [ ] TypeScript files in `web/ts/features/dashboard/` structure
|
- [ ] Router file created (`internal/router/dashboard.go`)
|
||||||
- [ ] Event delegation properly configured
|
- [ ] `/api/dashboard/sections` endpoint working
|
||||||
- [ ] Shared utilities imported and used
|
- [ ] User preferences applied (order, hidden sections)
|
||||||
- [ ] API client used for all fetch calls
|
- [ ] Collections with `show_on_dashboard` included
|
||||||
- [ ] Toast notifications integrated
|
- [ ] JSON response matches documentation
|
||||||
- [ ] Progressive enhancement maintained
|
- [ ] Bruno tests passing
|
||||||
|
|
||||||
### Integration (Phase 7-8):
|
### Frontend (Phases 7-9):
|
||||||
- [ ] Carousel scroll works (mouse and touch)
|
- [ ] Carousel scroll works (mouse and touch)
|
||||||
- [ ] Dashboard settings modal opens/closes
|
- [ ] Dashboard settings modal opens/closes
|
||||||
- [ ] Settings form saves correctly
|
- [ ] Settings form saves correctly
|
||||||
@@ -1548,9 +2041,17 @@ on('click', '[data-action="open-dashboard-settings"]', () => {
|
|||||||
- [ ] Library switching via HTMX
|
- [ ] Library switching via HTMX
|
||||||
- [ ] Book detail page displays correctly
|
- [ ] Book detail page displays correctly
|
||||||
|
|
||||||
|
### Documentation & Testing (Phase 12-13):
|
||||||
|
- [ ] API documentation created (`docs/developer/api/dashboard.md`)
|
||||||
|
- [ ] User documentation created (`docs/user/dashboard.md`)
|
||||||
|
- [ ] Bruno tests passing (all 5 existing tests)
|
||||||
|
- [ ] Three-context testing verified
|
||||||
|
- [ ] Documentation matches implementation
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Updated: 2025-02-17*
|
*Updated: 2025-02-17*
|
||||||
*Prerequisites: TypeScript Conversion Plan must be completed first*
|
*Prerequisites: TypeScript Conversion Plan must be completed first*
|
||||||
*Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md*
|
*Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md*
|
||||||
*Architecture: Hybrid SSR + TypeScript CRUD with Event Delegation*
|
*Architecture: Hybrid SSR + Generic API with Single Source of Truth*
|
||||||
|
*Key Changes: Added `/api/dashboard/sections` JSON endpoint, user preferences applied in service layer*
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# 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 |
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# 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
|
||||||
Reference in New Issue
Block a user