1623 lines
56 KiB
Markdown
1623 lines
56 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)
|
||
|
||
---
|
||
|
||
## 🏗️ 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 DSL tests for all new endpoints
|
||
|
||
✅ **Frontend Standards**:
|
||
- **TailwindCSS classes ONLY** - no custom CSS
|
||
- **TypeScript ONLY** - no JavaScript files
|
||
- **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)
|
||
|
||
✅ **Code Organization**:
|
||
- **Share handler types with templates** - no duplicate type systems
|
||
- **All business logic in services** - reusable for SSR/API/mobile
|
||
- **Minimal project structure changes** - contextually appropriate directories
|
||
|
||
✅ **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 .bru files** for all new endpoints
|
||
- **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"
|
||
)
|
||
|
||
type DashboardService struct {
|
||
db *database.Queries
|
||
}
|
||
|
||
// Section data - use handler types, not template types
|
||
type Section struct {
|
||
ID string `json:"id"`
|
||
Type string `json:"type"` // "smart", "collection"
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Icon string `json:"icon"`
|
||
Items []MediaItem `json:"items"`
|
||
ViewAllURL string `json:"view_all_url"`
|
||
Priority int `json:"priority"`
|
||
IsHidden bool `json:"is_hidden"`
|
||
}
|
||
|
||
// MediaItem - REUSE existing handler type
|
||
type MediaItem = database.MediaItem
|
||
|
||
// NewDashboardService creates service instance
|
||
func NewDashboardService(db *database.Queries) *DashboardService {
|
||
return &DashboardService{db: db}
|
||
}
|
||
|
||
// GetSections fetches all sections for dashboard
|
||
// REUSABLE by SSR handlers, API endpoints, mobile apps
|
||
func (s *DashboardService) GetSections(ctx context.Context, userID, libraryID uuid.UUID) ([]Section, error) {
|
||
// 1. Get user preferences
|
||
prefs, _ := s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
|
||
UserID: database.SetUUID(userID),
|
||
LibraryID: database.SetUUID(libraryID),
|
||
})
|
||
|
||
// 2. Get smart sections (use existing progress API)
|
||
smartSections := s.getSmartSections(ctx, userID, libraryID, prefs)
|
||
|
||
// 3. Get user collections marked for dashboard
|
||
collectionSections := s.getCollectionSections(ctx, userID, libraryID, prefs)
|
||
|
||
// 4. Merge and sort by priority/user order
|
||
return s.mergeAndSortSections(smartSections, collectionSections, prefs)
|
||
}
|
||
|
||
func (s *DashboardService) getSmartSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section {
|
||
// Use EXISTING APIs:
|
||
// - s.db.GetUniversalProgress for Continue Reading, In Progress, Recently Read
|
||
// - s.db.ListMediaItems for Recently Added
|
||
// NO direct database access - use queries
|
||
}
|
||
|
||
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, prefs database.UserDashboardPreferences) []Section {
|
||
// Query collections WHERE show_on_dashboard = true
|
||
// For each collection, fetch items using existing GetCollectionItems
|
||
}
|
||
|
||
func (s *DashboardService) mergeAndSortSections(smart, collections []Section, prefs database.UserDashboardPreferences) []Section {
|
||
// Merge by priority or user's section_order preference
|
||
}
|
||
```
|
||
|
||
**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)
|
||
|
||
---
|
||
|
||
### **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: HTTP Handlers** (2-3 hours)
|
||
|
||
**File: `internal/handlers/dashboard.go`** (new file)
|
||
|
||
**COMPLIANCE**: Thin handlers, all logic in service layer
|
||
|
||
```go
|
||
package handlers
|
||
|
||
import (
|
||
"bookhoard/internal/services"
|
||
"github.com/labstack/echo/v4"
|
||
)
|
||
|
||
type DashboardHandler struct {
|
||
db *database.Queries
|
||
dashboardSvc *services.DashboardService
|
||
}
|
||
|
||
// NewDashboardHandler creates handler instance
|
||
func NewDashboardHandler(db *database.Queries) *DashboardHandler {
|
||
return &DashboardHandler{
|
||
db: db,
|
||
dashboardSvc: services.NewDashboardService(db),
|
||
}
|
||
}
|
||
|
||
// GetDashboard renders full dashboard with pre-populated data (SSR)
|
||
func (h *DashboardHandler) GetDashboard(c echo.Context) error {
|
||
user := MustGetAuthenticatedUser(c)
|
||
libraryID := h.getSelectedLibrary(c, user.ID)
|
||
|
||
// CALL SERVICE (not database directly)
|
||
sections, err := h.dashboardSvc.GetSections(c.Request().Context(), user.ID, libraryID)
|
||
if err != nil {
|
||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load dashboard")
|
||
}
|
||
|
||
libraries, err := h.db.GetUserVisibleLibraries(c.Request().Context(), user.ID)
|
||
if err != nil {
|
||
return echo.NewHTTPError(http.StatusInternalServerError, "failed to load libraries")
|
||
}
|
||
|
||
// SSR - pre-populate all data, no client-side API calls
|
||
// Use handler types directly (database.Library, etc.) not template types
|
||
return c.Render(http.StatusOK, "dashboard", map[string]interface{}{
|
||
"User": user,
|
||
"Sections": sections,
|
||
"Libraries": libraries,
|
||
"CurrentLibraryID": libraryID,
|
||
})
|
||
}
|
||
|
||
// GetDashboardSections returns partial HTML for HTMX swap
|
||
func (h *DashboardHandler) GetDashboardSections(c echo.Context) error {
|
||
user := MustGetAuthenticatedUser(c)
|
||
libraryID, _ := uuid.Parse(c.QueryParam("library_id"))
|
||
|
||
sections, err := h.dashboardSvc.GetSections(c.Request().Context(), user.ID, libraryID)
|
||
if err != nil {
|
||
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
|
||
}
|
||
|
||
// Return partial template for HTMX
|
||
return c.Render(http.StatusOK, "dashboard-sections-partial", sections)
|
||
}
|
||
|
||
// UpdateDashboardPreferences handles settings updates (HTMX POST)
|
||
func (h *DashboardHandler) UpdateDashboardPreferences(c echo.Context) error {
|
||
user := MustGetAuthenticatedUser(c)
|
||
|
||
var req struct {
|
||
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 echo.NewHTTPError(http.StatusBadRequest, "invalid request")
|
||
}
|
||
|
||
// Update via service (through database queries)
|
||
// ...
|
||
}
|
||
```
|
||
|
||
**Routes to add to `internal/router/router.go`**:
|
||
```go
|
||
// Dashboard routes
|
||
dashboard := e.Group("/dashboard")
|
||
dashboard.GET("", cfg.DashboardHandler.GetDashboard)
|
||
dashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSections) // HTMX
|
||
dashboard.POST("/preferences", cfg.DashboardHandler.UpdateDashboardPreferences) // HTMX/API
|
||
|
||
// API for mobile apps
|
||
apiDashboard := protected.Group("/api/dashboard")
|
||
apiDashboard.GET("/sections", cfg.DashboardHandler.GetDashboardSectionsAPI) // JSON
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 5: Bruno API Tests** (1 hour)
|
||
|
||
**COMPLIANCE**: All new endpoints need Bruno DSL tests
|
||
|
||
**File: `bruno/dashboard/get-dashboard-sections.bru`** (new file)
|
||
|
||
```bruno
|
||
{
|
||
"meta": {
|
||
"type": "http",
|
||
"name": "Get Dashboard Sections",
|
||
"seq": 1
|
||
},
|
||
"req": {
|
||
"method": "GET",
|
||
"url": "{{baseUrl}}/api/dashboard/sections?library_id={{libraryId}}",
|
||
"headers": [
|
||
{
|
||
"name": "Authorization",
|
||
"value": "Bearer {{userToken}}"
|
||
}
|
||
]
|
||
},
|
||
"tests": {
|
||
"no_user": { "status": 401 },
|
||
"user": { "status": 200, "has": "sections" },
|
||
"admin": { "status": 200, "has": "sections" }
|
||
}
|
||
}
|
||
```
|
||
|
||
Create tests for:
|
||
1. ✅ `GET /api/dashboard/sections` (no user, user, admin)
|
||
2. ✅ `POST /api/dashboard/preferences` (user, admin)
|
||
3. ✅ Verify backward compatibility
|
||
|
||
---
|
||
|
||
### **Phase 6: Templates** (4-5 hours)
|
||
|
||
**COMPLIANCE**:
|
||
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
|
||
- ✅ Share handler types (no template.*Data types)
|
||
- ✅ SSR for initial data
|
||
- ✅ HTMX for updates
|
||
|
||
#### 6.1 Main Dashboard Template
|
||
**File: `templates/dashboard.templ`** (REPLACE existing)
|
||
|
||
```templ
|
||
package templates
|
||
|
||
import (
|
||
"bookhoard/internal/handlers"
|
||
)
|
||
|
||
// Use handler types directly
|
||
templ Dashboard(user handlers.User, sections []services.Section, libraries []handlers.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/carousel.js" defer></script>
|
||
<script src="/static/dashboard-settings.js" defer></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 onclick="openDashboardSettings()"
|
||
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
|
||
style="background-color: var(--bg-secondary);"
|
||
title="Customize Dashboard">
|
||
⚙️
|
||
</button>
|
||
<button onclick="location.reload()"
|
||
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)
|
||
|
||
<script src="/static/theme.js"></script>
|
||
</body>
|
||
</html>
|
||
}
|
||
```
|
||
|
||
#### 6.2 Section Carousel Component
|
||
**File: `templates/components.templ`** (new file)
|
||
|
||
```templ
|
||
package templates
|
||
|
||
templ SectionCarousel(section services.Section) {
|
||
<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"
|
||
onclick="scrollCarousel('{ section.ID }', -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"
|
||
onclick="scrollCarousel('{ section.ID }', 1)"
|
||
aria-label="Scroll right">
|
||
<span class="text-3xl pr-2" style="color: var(--text-primary);">›</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
templ BookCard(item handlers.MediaItem) {
|
||
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
|
||
transition-transform duration-200 hover:scale-105"
|
||
onclick="window.location.href='/book/{ item.ID }'"
|
||
tabindex="0"
|
||
role="button"
|
||
aria-label={ fmt.Sprintf("View %s", item.Title) }
|
||
onkeydown="if(event.key === 'Enter') window.location.href='/book/{ item.ID }'">
|
||
<!-- 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.Valid {
|
||
<img src={ item.CoverImagePath.String }
|
||
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.Valid {
|
||
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
|
||
{ item.Author.String }
|
||
</p>
|
||
}
|
||
</div>
|
||
}
|
||
|
||
templ DashboardSettingsModal(sections []services.Section) {
|
||
<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 onclick="closeDashboardSettings()"
|
||
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"
|
||
{ !section.IsHidden ? "checked" : "" }
|
||
onchange="toggleSectionVisibility('{ 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"
|
||
oninput="document.getElementById('items-count-display').textContent = this.value;">
|
||
</div>
|
||
|
||
<div class="flex justify-end gap-3">
|
||
<button onclick="closeDashboardSettings()"
|
||
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 onclick="saveDashboardSettings()"
|
||
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 []services.Section) {
|
||
for _, section := range sections {
|
||
@SectionCarousel(section)
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 7: TypeScript (Procedural)** (4-5 hours)
|
||
|
||
**COMPLIANCE**:
|
||
- ✅ TypeScript ONLY (no .js files)
|
||
- ✅ Procedural/imperative style (no classes, no OOP)
|
||
- ✅ Progressive enhancement (works without JS)
|
||
- ✅ HTMX for updates
|
||
|
||
#### 7.1 Carousel Interactions
|
||
**File: `web/src/carousel.ts`** (new file)
|
||
|
||
```typescript
|
||
// Procedural style - no classes, no OOP
|
||
// Functions that operate on DOM elements
|
||
|
||
const SCROLL_AMOUNT = 300;
|
||
|
||
export function scrollCarousel(sectionId: string, direction: number): void {
|
||
const track = document.getElementById(`carousel-track-${sectionId}`);
|
||
if (!track) return;
|
||
|
||
const scrollAmount = direction * SCROLL_AMOUNT;
|
||
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
|
||
}
|
||
|
||
export function initializeCarousels(): void {
|
||
// Add touch/swipe support
|
||
const tracks = document.querySelectorAll('.carousel-track');
|
||
|
||
tracks.forEach(track => {
|
||
let isDown = false;
|
||
let startX: number;
|
||
let scrollLeft: number;
|
||
|
||
track.addEventListener('mousedown', (e: MouseEvent) => {
|
||
isDown = true;
|
||
startX = e.pageX - (track as HTMLElement).offsetLeft;
|
||
scrollLeft = 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 as HTMLElement).offsetLeft;
|
||
const walk = (x - startX) * 2;
|
||
track.scrollLeft = scrollLeft - walk;
|
||
});
|
||
|
||
// Touch events for mobile
|
||
track.addEventListener('touchstart', (e: TouchEvent) => {
|
||
startX = e.touches[0].pageX - (track as HTMLElement).offsetLeft;
|
||
scrollLeft = track.scrollLeft;
|
||
});
|
||
|
||
track.addEventListener('touchmove', (e: TouchEvent) => {
|
||
const x = e.touches[0].pageX - (track as HTMLElement).offsetLeft;
|
||
const walk = (x - startX) * 2;
|
||
track.scrollLeft = scrollLeft - walk;
|
||
});
|
||
});
|
||
}
|
||
|
||
// Initialize on DOM ready
|
||
document.addEventListener('DOMContentLoaded', initializeCarousels);
|
||
```
|
||
|
||
#### 7.2 Dashboard Settings
|
||
**File: `web/src/dashboard-settings.ts`** (new file)
|
||
|
||
```typescript
|
||
// Procedural functions for modal and settings
|
||
|
||
export function openDashboardSettings(): void {
|
||
const modal = document.getElementById('dashboard-settings-modal');
|
||
if (modal) {
|
||
modal.classList.remove('hidden');
|
||
initializeDragAndDrop();
|
||
}
|
||
}
|
||
|
||
export function closeDashboardSettings(): void {
|
||
const modal = document.getElementById('dashboard-settings-modal');
|
||
if (modal) {
|
||
modal.classList.add('hidden');
|
||
}
|
||
}
|
||
|
||
function initializeDragAndDrop(): void {
|
||
const list = document.getElementById('section-list');
|
||
if (!list) return;
|
||
|
||
const items = list.querySelectorAll('.section-item');
|
||
|
||
items.forEach(item => {
|
||
item.addEventListener('dragstart', handleDragStart);
|
||
item.addEventListener('dragover', handleDragOver);
|
||
item.addEventListener('drop', handleDrop);
|
||
item.addEventListener('dragend', handleDragEnd);
|
||
});
|
||
}
|
||
|
||
function handleDragStart(e: DragEvent): void {
|
||
const target = e.target as HTMLElement;
|
||
target.style.opacity = '0.5';
|
||
}
|
||
|
||
function handleDragOver(e: DragEvent): void {
|
||
e.preventDefault();
|
||
}
|
||
|
||
function handleDrop(e: DragEvent): void {
|
||
e.preventDefault();
|
||
const target = e.target as HTMLElement;
|
||
// Reorder logic...
|
||
}
|
||
|
||
function handleDragEnd(e: DragEvent): void {
|
||
const target = e.target as HTMLElement;
|
||
target.style.opacity = '1';
|
||
}
|
||
|
||
export function toggleSectionVisibility(sectionId: string): void {
|
||
// Update local state, save on submit
|
||
}
|
||
|
||
export function saveDashboardSettings(): void {
|
||
const sectionList = document.getElementById('section-list');
|
||
const items = sectionList?.querySelectorAll('.section-item');
|
||
|
||
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"]');
|
||
if (checkbox && !(checkbox as HTMLInputElement).checked) {
|
||
hiddenSections.push(id);
|
||
}
|
||
});
|
||
|
||
const data = {
|
||
library_id: getCurrentLibraryId(),
|
||
hidden_sections: hiddenSections,
|
||
section_order: sectionOrder,
|
||
items_per_section: parseInt((document.getElementById('items-count-display') as HTMLElement).textContent)
|
||
};
|
||
|
||
fetch('/api/dashboard/preferences', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify(data)
|
||
})
|
||
.then(response => response.json())
|
||
.then(() => {
|
||
closeDashboardSettings();
|
||
location.reload(); // Or HTMX refresh
|
||
})
|
||
.catch(error => {
|
||
console.error('Failed to save settings:', error);
|
||
showToast('Failed to save settings', 'error');
|
||
});
|
||
}
|
||
|
||
function getCurrentLibraryId(): string {
|
||
const select = document.getElementById('library-select') as HTMLSelectElement;
|
||
return select?.value || '';
|
||
}
|
||
```
|
||
|
||
**Build setup**:
|
||
```json
|
||
// package.json - add TypeScript build
|
||
{
|
||
"scripts": {
|
||
"build:carousel": "esbuild web/src/carousel.ts --bundle --minify --outfile=web/static/carousel.js",
|
||
"build:dashboard-settings": "esbuild web/src/dashboard-settings.ts --bundle --minify --outfile=web/static/dashboard-settings.js"
|
||
}
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 8: Book Detail Page** (3-4 hours)
|
||
|
||
**File: `templates/book_detail.templ`** (new file)
|
||
|
||
**COMPLIANCE**: Use handler types, TailwindCSS, SSR
|
||
|
||
```templ
|
||
package templates
|
||
|
||
templ BookDetail(user handlers.User, book handlers.MediaItem, progress handlers.ReadingProgress, rating float64, collections []handlers.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/toast.js"></script>
|
||
<script src="/static/rating.js" defer></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 onclick="history.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="width: { progress.Percentage }%; background-color: var(--accent);"></div>
|
||
</div>
|
||
</div>
|
||
}
|
||
|
||
<!-- Action Buttons -->
|
||
<div class="flex flex-wrap gap-3 mb-6">
|
||
<button class="px-6 py-3 rounded-lg font-medium text-white hover:opacity-90 transition-opacity"
|
||
style="background-color: var(--accent);">
|
||
📖 Read Now
|
||
</button>
|
||
<button class="px-6 py-3 rounded-lg font-medium border hover:bg-gray-700 transition-colors"
|
||
style="border-color: var(--border); color: var(--text-primary);">
|
||
➕ Add to Collection
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Metadata Table -->
|
||
<div class="mb-6">
|
||
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Details</h3>
|
||
<table class="w-full text-sm">
|
||
@if book.Series.Valid {
|
||
<tr>
|
||
<td class="py-2 font-medium" style="color: var(--text-secondary); width: 150px;">Series</td>
|
||
<td class="py-2" style="color: var(--text-primary);">{ book.Series.String }</td>
|
||
</tr>
|
||
}
|
||
@if book.Genre.Valid {
|
||
<tr>
|
||
<td class="py-2 font-medium" style="color: var(--text-secondary);">Genre</td>
|
||
<td class="py-2" style="color: var(--text-primary);">{ book.Genre.String }</td>
|
||
</tr>
|
||
}
|
||
<tr>
|
||
<td class="py-2 font-medium" style="color: var(--text-secondary);">Pages</td>
|
||
<td class="py-2" style="color: var(--text-primary);">{ book.PageCount.Int32 }</td>
|
||
</tr>
|
||
</table>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
@if book.Description.Valid {
|
||
<div class="mb-6">
|
||
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary);">Synopsis</h3>
|
||
<p class="text-sm leading-relaxed" style="color: var(--text-secondary);">
|
||
{ book.Description.String }
|
||
</p>
|
||
</div>
|
||
}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<script src="/static/theme.js"></script>
|
||
</body>
|
||
</html>
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 9: Documentation** (1-2 hours)
|
||
|
||
**COMPLIANCE**: Update documentation per guidelines
|
||
|
||
#### 9.1 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
|
||
|
||
**Continue Reading**: Books you're currently reading, sorted by last read time.
|
||
|
||
**In Progress**: Books you've started but haven't finished.
|
||
|
||
**Recently Added**: Newest items added to your library (global across all users).
|
||
|
||
**Recently Read**: Books you've completed (100% progress).
|
||
|
||
**Not Started**: Books with no reading progress.
|
||
|
||
### User Collections
|
||
|
||
Any collection marked with "Show on Dashboard" will appear as a section.
|
||
|
||
### Customizing Your Dashboard
|
||
|
||
1. Click the ⚙️ (gear icon) in the top-right
|
||
2. Drag sections to reorder
|
||
3. Toggle visibility with switches
|
||
4. Adjust items per section (10-50)
|
||
5. Click "Save Changes"
|
||
|
||
### Library Switching
|
||
|
||
Use the dropdown in the sticky header to switch between libraries. Settings are per-library.
|
||
```
|
||
|
||
#### 9.2 API Documentation
|
||
**File: `docs/developer/api/dashboard/sections.md`** (new file)
|
||
|
||
```markdown
|
||
# Get Dashboard Sections
|
||
|
||
Returns all dashboard sections for the specified library.
|
||
|
||
## Endpoint
|
||
|
||
`GET /api/dashboard/sections`
|
||
|
||
## Query Parameters
|
||
|
||
| Parameter | Type | Required | Description |
|
||
|-----------|------|----------|-------------|
|
||
| library_id | string | Yes | Library UUID |
|
||
|
||
## Response
|
||
|
||
```json
|
||
{
|
||
"sections": [
|
||
{
|
||
"id": "continue-reading",
|
||
"type": "smart",
|
||
"title": "Continue Reading",
|
||
"icon": "📖",
|
||
"items": [...],
|
||
"view_all_url": "/section/continue-reading"
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
## Examples
|
||
|
||
See `bruno/dashboard/get-dashboard-sections.bru`
|
||
```
|
||
|
||
---
|
||
|
||
### **Phase 10: Testing & Verification** (2-3 hours)
|
||
|
||
**COMPLIANCE**: Follow testing guidelines from test_helpers.go
|
||
|
||
#### 10.1 Integration Tests
|
||
**File: `cmd/server/tests/dashboard_test.go`** (new file)
|
||
|
||
**Pattern**: Call `setupTestServer(t)` ONCE, use `t.Run()` for subtests
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"net/http"
|
||
"testing"
|
||
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
)
|
||
|
||
// TestDashboardSections tests the dashboard sections endpoint
|
||
func TestDashboardSections(t *testing.T) {
|
||
setup := setupTestServer(t) // ✅ Called ONCE per test function
|
||
client := &http.Client{}
|
||
|
||
// Get admin token
|
||
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_WithoutAuth", func(t *testing.T) {
|
||
req, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections?library_id="+libraryID, nil)
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
})
|
||
|
||
t.Run("GetSections_WithAdminUser", 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 := client.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, ok := result["sections"].([]interface{})
|
||
assert.True(t, ok, "Should have sections array")
|
||
assert.GreaterOrEqual(t, len(sections), 5, "Should have at least 5 smart sections")
|
||
})
|
||
|
||
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 := client.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, ok := result["sections"].([]interface{})
|
||
assert.True(t, ok, "Should have sections array")
|
||
assert.GreaterOrEqual(t, len(sections), 5, "Should have at least 5 smart sections")
|
||
})
|
||
|
||
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 := client.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||
})
|
||
}
|
||
|
||
// TestDashboardPreferences tests the preferences endpoints
|
||
func TestDashboardPreferences(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
token := loginTestUser(t, setup.Server, setup.DB)
|
||
client := &http.Client{}
|
||
|
||
// Create test library
|
||
deviceSetup := setupDeviceTest(t)
|
||
libraryID := deviceSetup.CreateLibrary(t, "Test Library", "ebooks")
|
||
|
||
t.Run("UpdatePreferences_Valid", func(t *testing.T) {
|
||
reqBody := map[string]interface{}{
|
||
"library_id": libraryID,
|
||
"hidden_sections": []string{"recently-added"},
|
||
"section_order": []string{"continue-reading", "in-progress"},
|
||
"items_per_section": 25,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
|
||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||
})
|
||
|
||
t.Run("UpdatePreferences_WithoutAuth", func(t *testing.T) {
|
||
reqBody := map[string]interface{}{
|
||
"library_id": libraryID,
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
|
||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/dashboard/preferences", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
// No authorization header
|
||
|
||
resp, err := client.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||
})
|
||
}
|
||
|
||
// TestDashboardCollectionVisibility tests collection show_on_dashboard functionality
|
||
func TestDashboardCollectionVisibility(t *testing.T) {
|
||
setup := setupTestServer(t)
|
||
token := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
t.Run("CreateCollection_WithDashboardVisibility", func(t *testing.T) {
|
||
deviceSetup := setupDeviceTest(t)
|
||
|
||
reqBody := map[string]interface{}{
|
||
"name": "Test Dashboard Collection",
|
||
"description": "A collection for testing dashboard",
|
||
"color": "#FF5733",
|
||
"icon": "📚",
|
||
}
|
||
body, _ := json.Marshal(reqBody)
|
||
|
||
req, _ := http.NewRequest("POST", setup.Server.URL+"/api/collections", bytes.NewBuffer(body))
|
||
req.Header.Set("Content-Type", "application/json")
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
resp, err := http.DefaultClient.Do(req)
|
||
require.NoError(t, err)
|
||
defer resp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusCreated, resp.StatusCode)
|
||
|
||
var result map[string]interface{}
|
||
json.NewDecoder(resp.Body).Decode(&result)
|
||
|
||
collectionID := result["id"].(string)
|
||
|
||
// Update to show on dashboard
|
||
updateReq := map[string]interface{}{
|
||
"show_on_dashboard": true,
|
||
}
|
||
updateBody, _ := json.Marshal(updateReq)
|
||
|
||
updateHTTP, _ := http.NewRequest("PUT", setup.Server.URL+"/api/collections/"+collectionID, bytes.NewBuffer(updateBody))
|
||
updateHTTP.Header.Set("Content-Type", "application/json")
|
||
updateHTTP.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
updateResp, err := http.DefaultClient.Do(updateHTTP)
|
||
require.NoError(t, err)
|
||
defer updateResp.Body.Close()
|
||
|
||
assert.Equal(t, http.StatusOK, updateResp.StatusCode)
|
||
|
||
// Verify it appears in dashboard sections
|
||
sectionsReq, _ := http.NewRequest("GET", setup.Server.URL+"/api/dashboard/sections", nil)
|
||
sectionsReq.Header.Set("Authorization", "Bearer "+token)
|
||
|
||
sectionsResp, err := http.DefaultClient.Do(sectionsReq)
|
||
require.NoError(t, err)
|
||
defer sectionsResp.Body.Close()
|
||
|
||
var sectionsResult map[string]interface{}
|
||
json.NewDecoder(sectionsResp.Body).Decode(§ionsResult)
|
||
|
||
sections := sectionsResult["sections"].([]interface{})
|
||
// Should include our collection
|
||
assert.Greater(t, len(sections), 5, "Should have smart sections + collection")
|
||
})
|
||
}
|
||
```
|
||
|
||
#### 10.2 Bruno API Tests
|
||
|
||
**Files Created** (`bruno/dashboard/`):
|
||
|
||
All Bruno files follow proper format (matching existing project files):
|
||
- ✅ Proper `meta` block with `name` (NO quotes around name value)
|
||
- ✅ Valid method blocks (get/post/put) with url, headers, body/query
|
||
- ✅ Tests block with `test()` function syntax (not assert blocks)
|
||
- ✅ Snake_case variables from environment (`base_url`, `user_token`, `library_id`)
|
||
- ✅ Proper `auth: inherit` for authenticated endpoints
|
||
- ✅ Balanced braces
|
||
- ✅ `docs` block with API documentation
|
||
|
||
**File Examples**:
|
||
|
||
**`get-dashboard-sections.bru`**:
|
||
```bru
|
||
meta {
|
||
name: Get Dashboard Sections
|
||
type: http
|
||
seq: 1
|
||
}
|
||
|
||
get {
|
||
url: {{base_url}}/api/dashboard/sections
|
||
body: none
|
||
auth: inherit
|
||
query: {
|
||
library_id: "{{library_id}}"
|
||
}
|
||
}
|
||
|
||
tests {
|
||
test("status must be 200 with auth", function() {
|
||
expect(res.status).to.eql(200);
|
||
});
|
||
|
||
test("response has sections array", function() {
|
||
const body = JSON.parse(res.body);
|
||
expect(body).to.have.property("sections");
|
||
expect(body.sections).to.be.an("array");
|
||
});
|
||
}
|
||
```
|
||
|
||
**Key Bruno Requirements** (matching existing files):
|
||
- ❌ `name: "Get Dashboard"` (wrong - has quotes)
|
||
- ✅ `name: Get Dashboard` (correct - no quotes)
|
||
- ❌ `{{baseUrl}}` (wrong - camelCase)
|
||
- ✅ `{{base_url}}` (correct - snake_case)
|
||
- ❌ `assert { assertions: [...] }` (wrong - old format)
|
||
- ✅ `test("name", function() { expect(...).to.eql(...) })` (correct)
|
||
- ✅ Use `auth: inherit` instead of manual Authorization headers
|
||
- ✅ Include `docs` block with API documentation
|
||
- ✅ Include `settings` block with `encodeUrl: true` and `timeout: 0`
|
||
|
||
**Complete Bruno Files Created**:
|
||
1. `get-dashboard-sections.bru` - Get sections for library
|
||
2. `update-preferences.bru` - Update user dashboard preferences
|
||
3. `get-sections-by-library.bru` - Filter sections by library
|
||
4. `create-collection-with-dashboard.bru` - Create collection with `show_on_dashboard: true`
|
||
5. `update-collection-visibility.bru` - Toggle collection dashboard visibility
|
||
|
||
**All 5 files validated successfully** ✅
|
||
|
||
#### 10.3 Important: Test Helper Guidelines
|
||
|
||
**From PROJECT_GUIDELINES.md**:
|
||
|
||
✅ **ALWAYS use `setupTestServer()` helper**:
|
||
- Call it ONCE per test function (not in subtests)
|
||
- Uses `max_conns=1` to prevent connection exhaustion
|
||
- Automatic cleanup via `t.Cleanup()` (no defer needed)
|
||
- Returns `*TestServerSetup` with DB, Server, Config
|
||
|
||
❌ **NEVER create separate database pools per test**:
|
||
- 78 tests × 4 connections (default) = 312 connections > PostgreSQL's 100 limit
|
||
- That's why we use `max_conns=1` in test configuration
|
||
|
||
✅ **Share one test setup across all subtests**:
|
||
```go
|
||
func TestDashboard(t *testing.T) {
|
||
setup := setupTestServer(t) // ✅ ONCE
|
||
token := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
t.Run("Subtest1", func(t *testing.T) { /* use setup */ })
|
||
t.Run("Subtest2", func(t *testing.T) { /* use setup */ })
|
||
}
|
||
```
|
||
|
||
❌ **NEVER call setupTestServer() in loops**:
|
||
```go
|
||
// ❌ WRONG - creates multiple DB pools
|
||
for _, tc := range cases {
|
||
setup := setupTestServer(t) // DON'T DO THIS
|
||
}
|
||
```
|
||
|
||
✅ **DO**:
|
||
```go
|
||
func TestFeature(t *testing.T) {
|
||
setup := setupTestServer(t) // ✅ ONCE per function
|
||
token := loginTestUser(t, setup.Server, setup.DB)
|
||
|
||
t.Run("Subtest1", func(t *testing.T) {
|
||
// Use setup, token
|
||
})
|
||
|
||
t.Run("Subtest2", func(t *testing.T) {
|
||
// Use same setup, token
|
||
})
|
||
}
|
||
```
|
||
|
||
❌ **DON'T**:
|
||
```go
|
||
func TestFeature(t *testing.T) {
|
||
t.Run("Subtest1", func(t *testing.T) {
|
||
setup := setupTestServer(t) // ❌ Creates extra DB connections
|
||
})
|
||
|
||
t.Run("Subtest2", func(t *testing.T) {
|
||
setup := setupTestServer(t) // ❌ Exhausts connection pool
|
||
})
|
||
}
|
||
```
|
||
|
||
**Three-Context Testing**:
|
||
```go
|
||
t.Run("WithoutAuth", func(t *testing.T) {
|
||
// No Authorization header → expect 401
|
||
})
|
||
|
||
t.Run("WithRegularUser", func(t *testing.T) {
|
||
token := loginRegularUser(t, setup.Server, setup.DB)
|
||
// Regular user context → expect 200/403 depending on endpoint
|
||
})
|
||
|
||
t.Run("WithAdmin", func(t *testing.T) {
|
||
token := loginTestUser(t, setup.Server, setup.DB)
|
||
// Admin context → expect 200
|
||
})
|
||
```
|
||
|
||
#### 10.4 Verification Checklist
|
||
|
||
Before committing:
|
||
```bash
|
||
# 1. Run verification script
|
||
bash scripts/verify-guidelines.sh
|
||
|
||
# 2. Build affected packages
|
||
go build ./internal/handlers
|
||
go build ./internal/services
|
||
go build ./templates
|
||
|
||
# 3. Run tests
|
||
go test ./cmd/server/tests/... -v -run TestDashboard
|
||
|
||
# 4. Check for TypeScript
|
||
# No .js files allowed, only .ts
|
||
|
||
# 5. Check for custom CSS
|
||
# Should only use TailwindCSS classes
|
||
|
||
# 6. Verify docs render
|
||
# Visit /docs endpoint and search for "dashboard"
|
||
```
|
||
|
||
---
|
||
|
||
## 🗂️ File Structure Summary
|
||
|
||
```
|
||
Modified Files (COMPLIANT with guidelines):
|
||
├── database/schema/schema.sql (Add tables, NO migration files)
|
||
├── internal/database/queries/queries.sql (Add dashboard queries)
|
||
├── internal/router/router.go (Add dashboard routes)
|
||
└── templates/dashboard.templ (Replace with SSR version)
|
||
|
||
New Files:
|
||
├── internal/services/dashboard_service.go (Reusable business logic)
|
||
├── internal/handlers/dashboard.go (Thin handlers, no logic)
|
||
├── templates/components.templ (Carousel, modal components)
|
||
├── templates/dashboard_sections_partial.templ (HTMX partial)
|
||
├── templates/book_detail.templ (Book detail page)
|
||
├── web/src/carousel.ts (Procedural TypeScript)
|
||
├── web/src/dashboard-settings.ts (Procedural TypeScript)
|
||
├── cmd/server/tests/dashboard_test.go (Integration tests using test_helpers)
|
||
├── bruno/dashboard/get-dashboard-sections.bru (API test)
|
||
├── bruno/dashboard/update-preferences.bru (API test)
|
||
├── docs/user/dashboard.md (User documentation)
|
||
└── docs/developer/api/dashboard/sections.md (API reference)
|
||
```
|
||
|
||
---
|
||
|
||
## ⏱️ Time Estimate Summary
|
||
|
||
| Phase | Description | Time |
|
||
|-------|-------------|------|
|
||
| 1 | Database schema changes (schema.sql, no migrations) | 2-3 hrs |
|
||
| 2 | Service layer (reusable for SSR/API/mobile) | 3-4 hrs |
|
||
| 3 | Database queries | 1-2 hrs |
|
||
| 4 | HTTP handlers (thin, logic in services) | 2-3 hrs |
|
||
| 5 | Bruno API tests (3 contexts) | 1 hr |
|
||
| 6 | Templates (TailwindCSS only, SSR, handler types) | 4-5 hrs |
|
||
| 7 | TypeScript (procedural, no OOP) | 4-5 hrs |
|
||
| 8 | Book detail page | 3-4 hrs |
|
||
| 9 | Documentation (docs/user, docs/developer/api) | 1-2 hrs |
|
||
| 10 | Testing & verification | 2-3 hrs |
|
||
| | **Total** | **23-30 hrs** |
|
||
|
||
---
|
||
|
||
## 🎯 Implementation Order (Sprint Structure)
|
||
|
||
**Sprint 1** (Foundation - Backend First):
|
||
1. Phase 1: Database schema changes
|
||
2. Phase 3: Database queries
|
||
3. Phase 2: Service layer (testable independently)
|
||
|
||
**Sprint 2** (Handlers & Tests):
|
||
4. Phase 4: HTTP handlers
|
||
5. Phase 5: Bruno API tests
|
||
6. Phase 9: Documentation
|
||
|
||
**Sprint 3** (Frontend):
|
||
7. Phase 6: Templates (dashboard, components)
|
||
8. Phase 8: Book detail page
|
||
|
||
**Sprint 4** (Interactivity):
|
||
9. Phase 7: TypeScript (carousel, settings)
|
||
10. Phase 10: Testing & verification
|
||
|
||
---
|
||
|
||
## 🎨 Smart Sections Definitions
|
||
|
||
| Section Key | Title | Icon | Data Source | Global? | View All URL |
|
||
|-------------|-------|------|-------------|---------|-------------|
|
||
| `continue-reading` | Continue Reading | 📖 | `GET /api/progress` | ❌ | `/section/continue-reading` |
|
||
| `in-progress` | In Progress | 📚 | `GET /api/progress` | ❌ | `/section/in-progress` |
|
||
| `recently-added` | Recently Added | 🆕 | `GET /api/media-items` | ✅ | `/section/recently-added` |
|
||
| `recently-read` | Recently Read | ✅ | `GET /api/progress` | ❌ | `/history` |
|
||
| `unread` | Not Started | 📕 | Media items LEFT JOIN progress WHERE null | ❌ | `/section/unread` |
|
||
|
||
---
|
||
|
||
## ✅ Pre-Commit Checklist
|
||
|
||
Before committing, verify:
|
||
|
||
**Backend**:
|
||
- [ ] Schema changes merged into `database/schema/schema.sql` (NO migration files)
|
||
- [ ] Regenerated database code with `sqlc generate`
|
||
- [ ] All business logic in `services/` (not handlers)
|
||
- [ ] Bruno .bru files created for all new endpoints
|
||
- [ ] Tests in `cmd/server/tests/dashboard_test.go` using setupTestServer() helper
|
||
- [ ] Tests cover 3 contexts (no user, regular user, admin)
|
||
- [ ] `setupTestServer(t)` called ONCE per test function (not in subtests)
|
||
- [ ] `go build ./...` succeeds
|
||
- [ ] `go test ./cmd/server/tests/... -v -run TestDashboard` passes
|
||
|
||
**Frontend**:
|
||
- [ ] Only TailwindCSS classes used (no custom CSS)
|
||
- [ ] Only TypeScript files (no .js files)
|
||
- [ ] Procedural style (no classes, no OOP)
|
||
- [ ] SSR for initial data (no AJAX on load)
|
||
- [ ] HTMX for CRUD operations
|
||
- [ ] Handler types used (no template.*Data duplicates)
|
||
- [ ] Progressive enhancement works without JS
|
||
|
||
**Documentation**:
|
||
- [ ] User docs updated in `docs/user/dashboard.md`
|
||
- [ ] API docs updated in `docs/developer/api/dashboard/`
|
||
- [ ] Docs render at `/docs` endpoint
|
||
- [ ] Search finds new content
|
||
|
||
**Verification**:
|
||
- [ ] `bash scripts/verify-guidelines.sh` passes (0 errors)
|
||
- [ ] Git diff shows only intended changes
|
||
- [ ] No secrets committed
|
||
|
||
---
|
||
|
||
## 🔗 Related Guidelines Compliance
|
||
|
||
This plan addresses PROJECT_GUIDELINES.md requirements:
|
||
|
||
✅ **Full-stack task with user approval** - backend modifications allowed
|
||
✅ **No migration files** - merge into existing schema.sql
|
||
✅ **Service layer architecture** - all logic in services, reusable
|
||
✅ **TypeScript only** - no JavaScript files
|
||
✅ **TailwindCSS only** - no custom CSS
|
||
✅ **Procedural style** - no OOP, classes, or this-capture
|
||
✅ **SSR for initial data** - no AJAX on page load
|
||
✅ **HTMX for updates** - library switching, settings
|
||
✅ **Share handler types** - no duplicate type systems
|
||
✅ **Bruno tests** - all new endpoints tested
|
||
✅ **Documentation** - docs/user and docs/developer/api updated
|
||
✅ **Backward compatibility** - mobile apps supported
|
||
|
||
---
|
||
|
||
**Created:** 2025-02-17
|
||
**Updated:** 2025-02-17
|
||
**Status:** Planning
|
||
**Priority:** High
|
||
**Estimated effort:** 23-30 hours
|
||
**Architecture:** SSR-First with HTMX for updates
|
||
**Compliance:** PROJECT_GUIDELINES.md ✅
|