Files
bookhoard/CAROUSEL_DASHBOARD_PLAN.md
T
john-okeefe 3758532d31 docs(dashboard): update plan to support post-TypeScript conversion patterns
- Add prerequisites section (TypeScript conversion must be completed first)
- Update execution order: TypeScript (16-21 days) then dashboard (3-4 days)
- Revise Phase 7 TypeScript implementation:
  - Change file locations from web/src/ to web/ts/features/dashboard/
  - Replace inline onclick with data-action attributes
  - Use shared apiClient instead of raw fetch()
  - Add event delegation with on() utility
  - Import showToast from core instead of global
  - Add type definitions matching Go handlers
- Update templates to load new TypeScript module paths
- Add summary of key changes from original plan
- Ensure consistency with TypeScript Conversion Plan patterns
2026-02-17 21:58:37 -05:00

1557 lines
59 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🎬 Carousel-Style Dashboard Redesign Plan
## Overview
Transform the current dashboard into a **production-ready** horizontal carousel layout like Audiobookshelf/Kavita, with:
- Smart sections (Continue Reading, Recently Added, etc.)
- User collections as sections
- Filter-based smart sections (custom collections with auto-assign rules)
- Separate dashboard per library
- Full accessibility, keyboard nav, and touch gestures
- **SSR-first architecture** (data pre-populated server-side, HTMX for updates)
---
## ⚠️ Prerequisites: TypeScript Conversion First
**IMPORTANT:** This plan assumes the **TypeScript Conversion Plan** has been completed first.
**Required Infrastructure from TypeScript Conversion Plan:**
-`web/ts/core/api.ts` - Centralized API client with auth
-`web/ts/core/toast.ts` - Toast notification system
-`web/ts/shared/events.ts` - Event delegation utilities
-`web/ts/core/storage.ts` - localStorage wrapper
-`web/ts/core/dom.ts` - DOM utilities (escapeHtml, etc.)
- ✅ Event delegation pattern established (data attributes)
- ✅ TypeScript compilation pipeline in place (`npm run build:ts`)
**Execution Order:**
1. Complete TypeScript Conversion Plan (16-21 days)
2. Execute this updated Carousel Dashboard Plan (3-4 days)
**Timeline:** 19-25 days total (no rework, consistent patterns)
---
## 🏗️ Architecture Compliance
### Project Guidelines Alignment
This plan **adheres to** all PROJECT_GUIDELINES.md requirements with explicit user approval for backend modifications to improve frontend/mobile experience.
**Key Compliance Points:**
**Full-Stack Task** (backend modifications approved):
- Database schema changes
- New service layer for reusable business logic
- New API endpoints for mobile app compatibility
- Bruno tests already created in `bruno/dashboard/`
**Frontend Standards** (Updated for Post-TypeScript Conversion):
- **TailwindCSS classes ONLY** - no custom CSS
- **TypeScript** in `web/ts/features/dashboard/` (no inline JavaScript)
- **Procedural/imperative style** - no OOP (classes, inheritance, this-capture)
- **SSR for initial data** - no AJAX on page load
- **Progressive enhancement** - works without JavaScript
- **HTMX for CRUD operations** (library switching, settings updates)
- **Event delegation pattern** - `data-action` attributes (no inline `onclick`)
- **Shared API client** - `apiClient` from `web/ts/core/api.ts`
**Code Organization**:
- **Template types in templates/types.go** - SectionData, BookCardData
- **All business logic in services** - reusable for SSR/API/mobile
- **TypeScript in web/ts/features/dashboard/** - follows TypeScript Conversion Plan structure
**Database Operations**:
- **Merge into existing schema.sql** - no migration files
- **Atomic schema changes** - complete success or rejection
- **Pre-production app** - database will be recreated after schema changes
- **pgx v5 standards** - proper connection handling
**API Documentation**:
- **Bruno tests** already created in `bruno/dashboard/`
- - Three-context testing (no user, user, admin)
- - Backward compatibility for mobile apps
- - `docs/developer/api/** documentation updates
---
## 📋 Implementation Plan
### **Phase 1: Database Schema Changes** (2-3 hours)
#### 1.1 Update Schema File (Not Migrations)
**File: `database/schema/schema.sql`** (MODIFY existing file)
**CRITICAL**: This is a pre-production app. After updating schema.sql, recreate database:
```bash
podman compose down -v # Delete volumes (loses all data)
podman compose up -d # Start fresh with new schema
```
**Add to schema.sql**:
```sql
-- Table: user_dashboard_preferences
CREATE TABLE user_dashboard_preferences (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
library_id UUID REFERENCES libraries(id) ON DELETE CASCADE,
hidden_sections TEXT[] DEFAULT '{}',
section_order TEXT[] DEFAULT '{}',
items_per_section INT DEFAULT 20,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(user_id, library_id)
);
-- Index for fast lookups
CREATE INDEX idx_dashboard_prefs_user_library ON user_dashboard_preferences(user_id, library_id);
-- Add column to existing collections table
ALTER TABLE collections ADD COLUMN IF NOT EXISTS show_on_dashboard BOOLEAN DEFAULT false;
-- Index for dashboard queries
CREATE INDEX IF NOT EXISTS idx_collections_dashboard ON collections(user_id, show_on_dashboard)
WHERE show_on_dashboard = true;
-- Predefined smart sections (system-level, not user-created)
CREATE TABLE smart_section_types (
id SERIAL PRIMARY KEY,
section_key TEXT UNIQUE NOT NULL,
title TEXT NOT NULL,
description TEXT,
icon TEXT,
default_priority INT,
is_global BOOLEAN DEFAULT false -- true = uses global data (Recently Added), false = per-user
);
-- Insert default sections
INSERT INTO smart_section_types (section_key, title, description, icon, default_priority, is_global) VALUES
('continue-reading', 'Continue Reading', 'Books you''re currently reading', '📖', 1, false),
('in-progress', 'In Progress', 'Books you''ve started but not finished', '📚', 2, false),
('recently-added', 'Recently Added', 'Newly added items to this library', '🆕', 3, true),
('recently-read', 'Recently Read', 'Books you''ve finished', '✅', 4, false),
('unread', 'Not Started', 'Books you haven''t read yet', '📕', 5, false);
```
#### 1.2 Regenerate Database Code
```bash
cd internal/database
sqlc generate
```
Verify:
-`models.go` has new structs
-`queries.sql` is ready for new queries
- ✅ No compilation errors
---
### **Phase 2: Service Layer** (3-4 hours)
**File: `internal/services/dashboard_service.go`** (new file)
**COMPLIANCE**: All business logic in reusable service (per guidelines)
```go
package services
import (
"context"
"bookhoard/internal/database"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgtype"
)
type DashboardService struct {
db *database.Queries
}
// NewDashboardService creates service instance
func NewDashboardService(db *database.Queries) *DashboardService {
return &DashboardService{db: db}
}
// SectionItems contains raw items for a section - handler formats into SectionData
type SectionItems struct {
SectionKey string
Items []database.MediaItems
}
// GetSectionItems fetches raw items for each section type
// Handler will format these into template.SectionData
func (s *DashboardService) GetSectionItems(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
var results []SectionItems
// 1. Continue Reading - items with progress > 0 and < 1
continueReading, _ := s.getContinueReading(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "continue-reading", Items: continueReading})
// 2. In Progress - items with progress > 0
inProgress, _ := s.getInProgress(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "in-progress", Items: inProgress})
// 3. Recently Added - newest items in library
recentlyAdded, _ := s.getRecentlyAdded(ctx, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-added", Items: recentlyAdded})
// 4. Recently Read - items with progress = 1
recentlyRead, _ := s.getRecentlyRead(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "recently-read", Items: recentlyRead})
// 5. Not Started - items with no progress
unread, _ := s.getUnread(ctx, userID, libraryID, limit)
results = append(results, SectionItems{SectionKey: "unread", Items: unread})
// 6. User collections marked for dashboard
collectionItems, _ := s.getCollectionSections(ctx, userID, libraryID, limit)
results = append(results, collectionItems...)
return results, nil
}
func (s *DashboardService) getContinueReading(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress > 0 AND progress < 1
// Ordered by last_read_at DESC
}
func (s *DashboardService) getInProgress(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress > 0
}
func (s *DashboardService) getRecentlyAdded(ctx context.Context, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items ORDER BY created_at DESC
}
func (s *DashboardService) getRecentlyRead(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items WHERE progress >= 1 (completed)
}
func (s *DashboardService) getUnread(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]database.MediaItems, error) {
// Query media items with no reading_progress record
}
func (s *DashboardService) getCollectionSections(ctx context.Context, userID, libraryID uuid.UUID, limit int) ([]SectionItems, error) {
// Query collections WHERE show_on_dashboard = true
// Return SectionItems for each collection
}
// GetDashboardPreferences fetches user preferences for a library
func (s *DashboardService) GetDashboardPreferences(ctx context.Context, userID, libraryID uuid.UUID) (database.UserDashboardPreferences, error) {
return s.db.GetDashboardPreferences(ctx, database.GetDashboardPreferencesParams{
UserID: pgtype.UUID{Bytes: userID, Valid: true},
LibraryID: pgtype.UUID{Bytes: libraryID, Valid: true},
})
}
```
**Key Points**:
- ✅ Service layer holds all business logic
- ✅ Reusable by SSR, API, mobile
- ✅ No direct database access from handlers
- ✅ Uses existing database queries
- ✅ Procedural/imperative style (no OOP)
- ✅ Returns raw data - handler formats for templates
---
### **Phase 3: Database Queries** (1-2 hours)
**File: `internal/database/queries/queries.sql`** (ADD to existing file)
```sql
-- name: GetDashboardPreferences :one
SELECT * FROM user_dashboard_preferences
WHERE user_id = $1 AND library_id = $2;
-- name: UpsertDashboardPreferences :one
INSERT INTO user_dashboard_preferences (user_id, library_id, hidden_sections, section_order, items_per_section)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (user_id, library_id)
DO UPDATE SET
hidden_sections = EXCLUDED.hidden_sections,
section_order = EXCLUDED.section_order,
items_per_section = EXCLUDED.items_per_section,
updated_at = NOW()
RETURNING *;
-- name: UpdateDashboardPreferences :one
UPDATE user_dashboard_preferences
SET hidden_sections = $2,
section_order = $3,
items_per_section = $4,
updated_at = NOW()
WHERE user_id = $1 AND library_id = $5
RETURNING *;
-- name: GetCollectionsForDashboard :many
SELECT c.* FROM collections c
WHERE c.user_id = $1
AND c.show_on_dashboard = true
ORDER BY c.created_at DESC;
-- name: SetCollectionDashboardVisibility :one
INSERT INTO collections (id, show_on_dashboard)
VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET
show_on_dashboard = EXCLUDED.show_on_dashboard
RETURNING *;
```
Regenerate: `cd internal/database && sqlc generate`
---
### **Phase 4: Routes** (1-2 hours)
**File: `internal/router/frontend.go`** (MODIFY existing file)
**COMPLIANCE**: Add inline routes following existing pattern
**Modify existing `/dashboard` route** (around line 105):
```go
// Dashboard page - modified to load sections SSR
frontendProtected.GET("/dashboard", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
// Get library ID from query param, or use first visible library
libraryID := c.QueryParam("library_id")
if libraryID == "" {
// Get user's first visible library
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err == nil && len(libraries) > 0 {
libUUID, _ := uuid.FromBytes(libraries[0].ID.Bytes[0:16])
libraryID = libUUID.String()
}
}
// Get sections from service
libUUID, _ := uuid.Parse(libraryID)
userUUID, _ := uuid.Parse(user.ID)
sectionItems, err := cfg.DashboardService.GetSectionItems(c.Request().Context(), userUUID, libUUID, 20)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading dashboard")
}
// Get libraries for selector
libraries, err := cfg.Queries.GetUserVisibleLibraries(c.Request().Context(), user.ID)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading libraries")
}
// Convert to template types
libData := make([]templates.LibraryData, len(libraries))
for i, lib := range libraries {
libUUID, _ := uuid.FromBytes(lib.ID.Bytes[0:16])
libData[i] = templates.LibraryData{
ID: libUUID.String(),
Name: lib.Name,
Description: lib.Description.String,
TypeName: lib.TypeName,
}
}
// Build sections
sections := buildSections(sectionItems, database.UserDashboardPreferences{})
var buf bytes.Buffer
err = templates.Dashboard(user, sections, libData, libraryID).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
```
**Add `/settings` route** (new, after `/admin/profile` route):
```go
// User settings page (moved from admin)
frontendProtected.GET("/settings", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
// Get user's full data including dashboard preferences
userUUID, _ := uuid.Parse(user.ID)
userDB, err := cfg.Queries.GetUser(c.Request().Context(), uuidToPGType(userUUID))
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user data")
}
// Get dashboard preferences
dashPrefs, _ := cfg.DashboardService.GetDashboardPreferences(
c.Request().Context(),
userUUID,
uuid.Nil, // Get default preferences
)
var buf bytes.Buffer
err = templates.Settings(user, userDB, dashPrefs).Render(c.Request().Context(), &buf)
if err != nil {
return err
}
return c.HTML(http.StatusOK, buf.String())
})
frontendProtected.POST("/settings", func(c echo.Context) error {
user, err := getTemplateUserWithTheme(c, cfg)
if err != nil {
return c.HTML(http.StatusInternalServerError, "Error loading user")
}
var req struct {
Email string `json:"email"`
Username string `json:"username"`
FirstName string `json:"first_name"`
LastName string `json:"last_name"`
Theme string `json:"theme"`
// Dashboard preferences
LibraryID string `json:"library_id"`
HiddenSections []string `json:"hidden_sections"`
SectionOrder []string `json:"section_order"`
ItemsPerSection int `json:"items_per_section"`
}
if err := c.Bind(&req); err != nil {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid request"})
}
userUUID, _ := uuid.Parse(user.ID)
libUUID, _ := uuid.Parse(req.LibraryID)
// Update user info
_, err = cfg.Queries.UpdateUser(c.Request().Context(), database.UpdateUserParams{
ID: uuidToPGType(userUUID),
Email: pgtype.Text{String: req.Email, Valid: true},
Username: req.Username,
Theme: pgtype.Text{String: req.Theme, Valid: true},
FirstName: pgtype.Text{String: req.FirstName, Valid: true},
LastName: pgtype.Text{String: req.LastName, Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to update settings"})
}
// Save dashboard preferences
_, err = cfg.DashboardService.UpsertDashboardPreferences(c.Request().Context(), database.UpsertDashboardPreferencesParams{
UserID: uuidToPGType(userUUID),
LibraryID: uuidToPGType(libUUID),
HiddenSections: req.HiddenSections,
SectionOrder: req.SectionOrder,
ItemsPerSection: pgtype.Int4{Int32: int32(req.ItemsPerSection), Valid: true},
})
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to save preferences"})
}
// Return updated user data
updatedUser, _ := getTemplateUserWithTheme(c, cfg)
return c.JSON(http.StatusOK, updatedUser)
})
```
**Add to `internal/router/router.go` Config struct** (around line 34):
```go
type Config struct {
// ... existing fields ...
DashboardService *services.DashboardService
}
```
**Note**: No separate handler file needed. Following existing pattern, routes are inline in `frontend.go` and call service methods directly.
**Add helper function to `internal/router/frontend.go`**:
```go
// Smart section definitions (static metadata)
var smartSectionDefs = map[string]struct {
Title string
Description string
Icon string
ViewAllURL string
Priority int
}{
"continue-reading": {"Continue Reading", "Books you're currently reading", "📖", "/section/continue-reading", 1},
"in-progress": {"In Progress", "Books you've started but not finished", "📚", "/section/in-progress", 2},
"recently-added": {"Recently Added", "Newly added items to this library", "🆕", "/section/recently-added", 3},
"recently-read": {"Recently Read", "Books you've finished", "✅", "/history", 4},
"unread": {"Not Started", "Books you haven't read yet", "📕", "/section/unread", 5},
}
// buildSections converts service SectionItems to template SectionData
func buildSections(items []services.SectionItems, prefs database.UserDashboardPreferences) []templates.SectionData {
var sections []templates.SectionData
for _, si := range items {
def, isSmart := smartSectionDefs[si.SectionKey]
var title, description, icon, viewAllURL string
var priority int
var sectionType string
if isSmart {
title = def.Title
description = def.Description
icon = def.Icon
viewAllURL = def.ViewAllURL
priority = def.Priority
sectionType = "smart"
} else {
// Collection section
title = si.SectionKey
sectionType = "collection"
icon = "📚"
priority = 100
}
// Convert database.MediaItems to template.BookCardData
bookCards := make([]templates.BookCardData, len(si.Items))
for i, item := range si.Items {
itemUUID, _ := uuid.FromBytes(item.ID.Bytes[0:16])
bookCards[i] = templates.BookCardData{
ID: itemUUID.String(),
Title: item.Title,
Author: item.Author.String,
CoverImagePath: item.CoverImagePath.String,
}
}
sections = append(sections, templates.SectionData{
ID: si.SectionKey,
Type: sectionType,
Title: title,
Description: description,
Icon: icon,
Items: bookCards,
ViewAllURL: viewAllURL,
Priority: priority,
})
}
return sections
}
```
---
### **Phase 5: Template Types** (30 min)
**File: `templates/types.go`** (ADD to existing file)
Add new types to support dashboard:
```go
// SectionData represents a dashboard section (carousel)
type SectionData struct {
ID string `json:"id"`
Type string `json:"type"` // "smart", "collection"
Title string `json:"title"`
Description string `json:"description"`
Icon string `json:"icon"`
Items []BookCardData `json:"items"`
ViewAllURL string `json:"view_all_url"`
Priority int `json:"priority"`
IsHidden bool `json:"is_hidden"`
}
// BookCardData represents a book in a carousel card
type BookCardData struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
CoverImagePath string `json:"cover_image_path"`
}
```
---
### **Phase 5: Settings Template** (2 hours)
**COMPLIANCE**: Use template types, TailwindSSR, SSR
**File: `templates/settings.templ`** (new file)
```templ
package templates
import (
"bookhoard/internal/database"
)
templ Settings(user User, userDB database.Users, dashPrefs database.UserDashboardPreferences) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/settings")
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-3xl font-bold mb-8" style="color: var(--text-primary)">Settings</h1>
<form id="settings-form" data-action="save-settings">
<!-- Profile Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Profile</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Email</label>
<input type="email" name="email" value={ userDB.Email }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Username</label>
<input type="text" name="username" value={ userDB.Username }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">First Name</label>
<input type="text" name="first_name" value={ userDB.FirstName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
<div>
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Last Name</label>
<input type="text" name="last_name" value={ userDB.LastName.String }
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
</div>
</div>
</div>
<!-- Theme Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Appearance</h2>
<div>
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">Theme</label>
<select name="theme"
class="w-full px-4 py-2 rounded-lg border"
style="background-color: var(--bg-primary); color: var(--text-primary); border-color: var(--border);">
<option value="tokyo-night" { user.Theme == "tokyo-night" { "selected" } }>Tokyo Night</option>
<option value="light" { user.Theme == "light" { "selected" } }>Light</option>
<option value="dark" { user.Theme == "dark" { "selected" } }>Dark</option>
</select>
</div>
</div>
<!-- Dashboard Section -->
<div class="card p-6 rounded-lg mb-6" style="background-color: var(--bg-secondary); border-color: var(--border);">
<h2 class="text-xl font-semibold mb-4" style="color: var(--text-primary)">Dashboard Preferences</h2>
<div class="mb-4">
<label class="block text-sm font-medium mb-1" style="color: var(--text-secondary)">Items Per Section</label>
<input type="range" name="items_per_section" min="10" max="50" step="5"
value={ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }
class="w-full"
data-action="update-items-display"
target="items-display">
<div class="text-sm text-right mt-1" style="color: var(--text-secondary)">
<span id="items-display">{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) }</span> items
</div>
</div>
<p class="text-sm" style="color: var(--text-secondary)">
Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.
</p>
</div>
<!-- Submit -->
<div class="flex justify-end gap-3">
<button type="button" data-action="cancel"
class="px-6 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button type="submit"
class="px-6 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Settings
</button>
</div>
</form>
</div>
<script src="/static/shared/events.js"></script>
<script src="/static/features/settings.js"></script>
<script src="/static/theme.js"></script>
</body>
</html>
}
```
---
### **Phase 6: Templates** (4-5 hours)
**COMPLIANCE**:
- ✅ Use TailwindCSS classes ONLY (no custom CSS)
- ✅ Use template types (SectionData, BookCardData, User, LibraryData)
- ✅ SSR for initial data
- ✅ HTMX for updates
-**Event delegation pattern** (no inline onclick)
-**Data attributes** for TypeScript integration
#### 6.1 Main Dashboard Template
**File: `templates/dashboard.templ`** (REPLACE existing)
```templ
package templates
templ Dashboard(user User, sections []SectionData, libraries []LibraryData, currentLibraryID string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<script src="/static/core/api.js"></script>
<script src="/static/shared/events.js"></script>
<script src="/static/features/dashboard/carousel.js"></script>
<script src="/static/features/dashboard/settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "/dashboard")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select id="library-select"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
hx-get="/dashboard/sections?library_id={ currentLibraryID }"
hx-target="#sections-container"
hx-indicator="#loading-spinner"
hx-swap="innerHTML">
for _, lib := range libraries {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
<div class="flex items-center gap-2">
<button data-action="open-dashboard-settings"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Customize Dashboard">
⚙️
</button>
<button data-action="reload-page"
class="p-2 rounded-lg hover:bg-gray-700 transition-colors"
style="background-color: var(--bg-secondary);"
title="Refresh">
🔄
</button>
</div>
</div>
<!-- HTMX Loading Indicator (hidden by default) -->
<div id="loading-spinner" class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);">
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
</div>
<!-- Sections Container (HTMX swap target) -->
<main id="sections-container" class="max-w-7xl mx-auto px-4 py-8">
for _, section := range sections {
@SectionCarousel(section)
}
</main>
<!-- Dashboard Settings Modal -->
@DashboardSettingsModal(sections)
</body>
</html>
}
```
#### 6.2 Section Carousel Component
**File: `templates/components.templ`** (ADD to existing file if exists, or new file)
```templ
package templates
templ SectionCarousel(section SectionData) {
<div class="dashboard-section mb-8"
data-section-id={ section.ID }
data-section-type={ section.Type }>
<!-- Section Header -->
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<span class="text-2xl">{ section.Icon }</span>
<div>
<h2 class="text-xl font-bold" style="color: var(--text-primary)">{ section.Title }</h2>
if section.Description != "" {
<p class="text-sm" style="color: var(--text-secondary)">{ section.Description }</p>
}
</div>
</div>
<a href={ section.ViewAllURL }
class="text-sm font-medium hover:underline transition-colors"
style="color: var(--accent);">
View All
</a>
</div>
<!-- Carousel -->
<div class="carousel-container relative group">
<!-- Left Navigation -->
<button class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-r from-gray-900 to-transparent
flex items-center justify-start opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="-1"
aria-label="Scroll left">
<span class="text-3xl pl-2" style="color: var(--text-primary);"></span>
</button>
<!-- Track -->
<div id="carousel-track-{ section.ID }"
class="carousel-track flex gap-4 overflow-x-auto
scroll-smooth snap-x snap-mandatory
px-12 pb-4"
style="scrollbar-width: none; -ms-overflow-style: none;">
for _, item := range section.Items {
@BookCard(item)
}
if len(section.Items) == 0 {
<div class="text-center py-8 w-full" style="color: var(--text-secondary);">
<p>No items in this section</p>
</div>
}
</div>
<!-- Right Navigation -->
<button class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10
w-12 h-full bg-gradient-to-l from-gray-900 to-transparent
flex items-center justify-end opacity-0 group-hover:opacity-100
transition-opacity duration-200"
data-action="scroll-carousel"
data-section-id={ section.ID }
data-direction="1"
aria-label="Scroll right">
<span class="text-3xl pr-2" style="color: var(--text-primary);"></span>
</button>
</div>
</div>
}
templ BookCard(item BookCardData) {
<div class="book-card flex-shrink-0 w-32 snap-start cursor-pointer
transition-transform duration-200 hover:scale-105"
data-action="view-book"
data-book-id={ item.ID }
tabindex="0"
role="button"
aria-label={ "View " + item.Title }>
<!-- Cover -->
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-lg mb-2
bg-gradient-to-br from-gray-700 to-gray-900">
if item.CoverImagePath != "" {
<img src={ item.CoverImagePath }
alt={ item.Title }
class="w-full h-full object-cover"
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ item.Title }
class="w-full h-full object-cover">
}
</div>
<!-- Title -->
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">
{ item.Title }
</h3>
<!-- Author -->
if item.Author != "" {
<p class="text-xs line-clamp-1" style="color: var(--text-secondary)">
{ item.Author }
</p>
}
</div>
}
templ DashboardSettingsModal(sections []SectionData) {
<div id="dashboard-settings-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="rounded-lg p-6 w-full max-w-2xl mx-4 shadow-2xl"
style="background-color: var(--bg-secondary);">
<div class="flex justify-between items-center mb-6">
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
<button data-action="close-dashboard-settings"
class="p-2 hover:bg-gray-700 rounded transition-colors">
</button>
</div>
<p class="text-sm mb-4" style="color: var(--text-secondary);">
Drag to reorder sections, toggle visibility with the switch.
</p>
<!-- Draggable Section List -->
<div id="section-list" class="space-y-2 mb-6">
for _, section := range sections {
<div class="section-item flex items-center justify-between p-3 rounded border
cursor-move select-none"
data-section-id={ section.ID }
draggable="true"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex items-center gap-3">
<span class="text-xl" style="color: var(--text-secondary);"></span>
<span class="text-xl">{ section.Icon }</span>
<span class="font-medium" style="color: var(--text-primary);">{ section.Title }</span>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox"
class="sr-only peer"
checked
data-action="toggle-section-visibility"
data-section-id={ section.ID }>
<div class="w-11 h-6 bg-gray-600 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-800 rounded-full peer
peer-checked:after:translate-x-full peer-checked:after:border-white
after:content-[''] after:absolute after:top-[2px] after:left-[2px]
after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all
peer-checked:bg-blue-600"></div>
</label>
</div>
}
</div>
<!-- Items Per Section Slider -->
<div class="mb-6">
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
Items per Section: <span id="items-count-display" class="font-bold">20</span>
</label>
<input type="range" min="10" max="50" step="5" value="20"
class="w-full h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer"
data-action="update-items-count"
target="items-count-display">
</div>
<div class="flex justify-end gap-3">
<button data-action="close-dashboard-settings"
class="px-4 py-2 rounded-lg border hover:bg-gray-700 transition-colors"
style="border-color: var(--border); color: var(--text-primary);">
Cancel
</button>
<button data-action="save-dashboard-settings"
class="px-4 py-2 rounded-lg text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Save Changes
</button>
</div>
</div>
</div>
}
```
#### 6.3 HTMX Partial Template
**File: `templates/dashboard_sections_partial.templ`** (new file)
```templ
package templates
templ DashboardSectionsPartial(sections []SectionData) {
for _, section := range sections {
@SectionCarousel(section)
}
}
```
---
### **Phase 7: TypeScript** (REVISED - 2-3 hours)
**COMPLIANCE** (Post-TypeScript Conversion):
- ✅ TypeScript files in `web/ts/features/dashboard/` (not `web/src/`)
- ✅ Uses shared infrastructure from TypeScript Conversion Plan
- ✅ Event delegation pattern (no inline onclick handlers)
- ✅ Procedural/imperative style (no OOP)
- ✅ Type definitions matching Go handlers templates/types.go
- ✅ Uses `apiClient` from `web/ts/core/api.ts`
- ✅ Uses `showToast` from `web/ts/core/toast.ts`
- ✅ Uses `on` from `web/ts/shared/events.ts`
#### 7.1 Dashboard Carousel TypeScript
**File: `web/ts/features/dashboard/carousel.ts`** (new file, updated location)
```typescript
// Carousel scroll functionality
// Procedural/imperative style (no OOP)
// Uses shared event delegation system
import { on } from '../../shared/events.js';
const SCROLL_AMOUNT = 300;
// Pure function for scrolling carousel
const scrollCarousel = (sectionId: string, direction: number): void => {
const track = document.getElementById(`carousel-track-${sectionId}`) as HTMLElement;
if (!track) return;
const scrollAmount = direction * SCROLL_AMOUNT;
track.scrollBy({ left: scrollAmount, behavior: 'smooth' });
};
// Initialize drag-to-scroll on all carousel tracks
const initializeCarousels = (): void => {
const tracks = document.querySelectorAll('.carousel-track') as NodeListOf<HTMLElement>;
tracks.forEach(track => {
let isDown = false;
let startX: number;
let scrollLeftPos: number;
track.addEventListener('mousedown', (e: MouseEvent) => {
isDown = true;
startX = e.pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('mouseleave', () => isDown = false);
track.addEventListener('mouseup', () => isDown = false);
track.addEventListener('mousemove', (e: MouseEvent) => {
if (!isDown) return;
e.preventDefault();
const x = e.pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
// Touch events for mobile
track.addEventListener('touchstart', (e: TouchEvent) => {
startX = e.touches[0].pageX - track.offsetLeft;
scrollLeftPos = track.scrollLeft;
});
track.addEventListener('touchmove', (e: TouchEvent) => {
const x = e.touches[0].pageX - track.offsetLeft;
const walk = (x - startX) * 2;
track.scrollLeft = scrollLeftPos - walk;
});
});
};
// Event delegation for carousel scroll buttons
on('click', '[data-action="scroll-carousel"]', (target: HTMLElement) => {
const sectionId = target.dataset.sectionId;
const direction = parseInt(target.dataset.direction || '0', 10);
if (sectionId && !isNaN(direction)) {
scrollCarousel(sectionId, direction);
}
});
// Auto-initialize when DOM is ready
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeCarousels);
} else {
initializeCarousels();
}
}
```
#### 7.2 Dashboard Settings TypeScript
**File: `web/ts/features/dashboard/settings.ts`** (new file, updated location)
```typescript
// Dashboard settings modal functionality
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
import { apiClient } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { on } from '../../shared/events.js';
// Type definitions matching Go templates/types.go
interface SectionData {
ID: string;
Title: string;
Icon: string;
}
// Open dashboard settings modal
const openDashboardSettings = (): void => {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.remove('hidden');
initializeDragAndDrop();
}
};
// Close dashboard settings modal
const closeDashboardSettings = (): void => {
const modal = document.getElementById('dashboard-settings-modal');
if (modal) {
modal.classList.add('hidden');
}
};
// Initialize drag-and-drop for section reordering
const initializeDragAndDrop = (): void => {
const list = document.getElementById('section-list');
if (!list) return;
const items = list.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;
items.forEach(item => {
item.addEventListener('dragstart', handleDragStart);
item.addEventListener('dragover', handleDragOver);
item.addEventListener('drop', handleDrop);
item.addEventListener('dragend', handleDragEnd);
});
};
// Drag event handlers
const handleDragStart = (e: DragEvent): void => {
const target = e.target as HTMLElement;
target.style.opacity = '0.5';
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
}
};
const handleDragOver = (e: DragEvent): void => {
e.preventDefault();
if (e.dataTransfer) {
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (e: DragEvent): void => {
e.preventDefault();
// TODO: Implement reorder logic - swap with target element
};
const handleDragEnd = (e: DragEvent): void => {
const target = e.target as HTMLElement;
target.style.opacity = '1';
};
// Toggle section visibility (checkbox handler)
const toggleSectionVisibility = (sectionId: string): void => {
// Update local state, save on submit
// The checkbox state is managed by HTML
};
// Save dashboard settings to server
const saveDashboardSettings = async (): Promise<void> => {
const sectionList = document.getElementById('section-list');
const items = sectionList?.querySelectorAll('.section-item') as NodeListOf<HTMLElement>;
const sectionOrder: string[] = [];
const hiddenSections: string[] = [];
items?.forEach(item => {
const id = item.getAttribute('data-section-id');
if (!id) return;
sectionOrder.push(id);
const checkbox = item.querySelector('input[type="checkbox"]') as HTMLInputElement;
if (checkbox && !checkbox.checked) {
hiddenSections.push(id);
}
});
const data = {
library_id: getCurrentLibraryId(),
hidden_sections: hiddenSections,
section_order: sectionOrder,
items_per_section: parseInt(document.getElementById('items-count-display')?.textContent || '20')
};
try {
await apiClient.post('/settings', data);
showToast.success('Dashboard settings saved');
closeDashboardSettings();
location.reload();
} catch (error) {
showToast.error('Failed to save dashboard settings');
}
};
// Helper to get current library ID from selector
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for dashboard settings
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
on('click', '[data-action="close-dashboard-settings"]', () => {
closeDashboardSettings();
});
on('click', '[data-action="save-dashboard-settings"]', () => {
saveDashboardSettings();
});
on('input', '[data-action="update-items-display"]', (target: HTMLElement) => {
const displayElement = document.getElementById(target.dataset.target || 'items-display');
if (displayElement) {
displayElement.textContent = (target as HTMLInputElement).value;
}
});
```
#### 7.3 Settings Page TypeScript
**File: `web/ts/features/settings/settings.ts`** (new file)
```typescript
// Settings page form handling
// Procedural/imperative style (no OOP)
// Uses shared infrastructure from TypeScript Conversion Plan
import { apiClient } from '../../core/api.js';
import { showToast } from '../../core/toast.js';
import { on } from '../../shared/events.js';
// Save user settings (email, username, theme, etc.)
const saveSettings = async (event: Event): Promise<void> => {
event.preventDefault();
const form = event.target as HTMLFormElement;
const formData = new FormData(form);
const data = {
email: formData.get('email') as string,
username: formData.get('username') as string,
first_name: formData.get('first_name') as string,
last_name: formData.get('last_name') as string,
theme: formData.get('theme') as string,
// Dashboard preferences are saved separately via dashboard modal
library_id: getCurrentLibraryId(),
};
try {
const response = await apiClient.post('/settings', data);
// Response should contain updated user data including theme
showToast.success('Settings saved successfully');
// Update theme immediately
const theme = formData.get('theme') as string;
if (theme) {
document.body.className = `theme-${theme}`;
}
} catch (error) {
showToast.error('Failed to save settings');
}
};
// Helper to get current library ID
const getCurrentLibraryId = (): string => {
const select = document.getElementById('library-select') as HTMLSelectElement;
return select?.value || '';
};
// Event delegation for settings form
on('submit', '[data-action="save-settings"]', saveSettings);
on('click', '[data-action="cancel"]', () => {
history.back();
});
```
#### 7.4 Template Updates
**Update `templates/dashboard.templ` head section** (already shown above in Phase 6.1)
**Add `templates/settings.templ` head section**:
```templ
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Settings - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<script src="/static/core/api.js"></script>
<script src="/static/shared/events.js"></script>
<script src="/static/features/settings/settings.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
```
**Key Points**:
- ✅ TypeScript files in `web/ts/features/dashboard/` structure (follows TypeScript Conversion Plan)
- ✅ Uses shared utilities (`apiClient`, `showToast`, `on` event delegation)
- ✅ Event delegation pattern (no onclick handlers, data-action attributes)
- ✅ Type-safe API calls and error handling
- ✅ Compiled via existing `npm run build:ts`
- ✅ Matches procedural/imperative style (no OOP)
---
### **Phase 8: Book Detail Page** (3-4 hours)
**File: `templates/book_detail.templ`** (new file)
**COMPLIANCE**: Use template types, TailwindCSS, SSR
```templ
package templates
import (
"bookhoard/internal/database"
"fmt"
)
templ BookDetail(user User, book database.MediaItems, progress database.ReadingProgress, rating float64, collections []CollectionData) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{ book.Title } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<script src="/static/core/toast.js"></script>
<link href="/static/style.css" rel="stylesheet">
</head>
<body class="theme-{ user.Theme }">
@Header(user, "")
<div class="max-w-7xl mx-auto px-4 py-8">
<!-- Back Button -->
<button data-action="go-back"
class="mb-6 text-sm hover:underline transition-colors"
style="color: var(--text-secondary);">
Back to Dashboard
</button>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<!-- Cover Image -->
<div class="md:col-span-1">
<div class="aspect-[2/3] rounded-lg overflow-hidden shadow-2xl">
if book.CoverImagePath.Valid {
<img src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-full h-full object-cover"
onerror="this.src='/static/placeholder-book.svg'">
} else {
<img src="/static/placeholder-book.svg"
alt={ book.Title }
class="w-full h-full object-cover">
}
</div>
</div>
<!-- Details -->
<div class="md:col-span-2">
<h1 class="text-3xl font-bold mb-2" style="color: var(--text-primary);">
{ book.Title }
</h1>
if book.Author.Valid {
<p class="text-xl mb-4" style="color: var(--text-secondary);">
by { book.Author.String }
</p>
}
<!-- Reading Progress -->
if progress.Percentage > 0 {
<div class="mb-6 p-4 rounded-lg" style="background-color: var(--bg-secondary);">
<div class="flex justify-between text-sm mb-2">
<span style="color: var(--text-secondary);">Reading Progress</span>
<span style="color: var(--text-primary);">
{ fmt.Sprintf("%.0f%%", progress.Percentage) }
</span>
</div>
<div class="w-full h-2 rounded-full" style="background-color: var(--bg-primary);">
<div class="h-full rounded-full transition-all"
style={ fmt.Sprintf("width: %.0f%%; background-color: var(--accent);", progress.Percentage * 100) }></div>
</div>
</div>
}
<!-- Actions -->
<div class="flex gap-2 mb-6">
<button data-action="add-to-collection"
class="px-4 py-2 rounded-lg border text-white font-medium hover:opacity-90 transition-opacity"
style="background-color: var(--accent);">
Add to Collection
</button>
<button data-action="mark-as-read"
class="px-4 py-2 rounded-lg border hover:opacity-80 transition-opacity"
style="border-color: var(--border); color: var(--text-primary);">
Mark as Read
</button>
</div>
<!-- Description/Synopsis -->
{ book.Synopsis.Valid }
<div class="p-4 mb-6 rounded-lg" style="background-color: var(--bg-secondary); color: var(--text-primary);">
{ book.Synopsis.String }
</div>
{ end }
<!-- Collections -->
if len(collections) > 0 {
<div class="mb-6">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-2">
for _, collection := range collections {
<span class="px-3 py-1 rounded-full text-sm" style="background-color: { collection.Color }; color: var(--text-primary);">
{ collection.Icon } { collection.Name }
</span>
}
</div>
</div>
}
</div>
</div>
</div>
<script src="/static/shared/events.js"></script>
<script src="/static/features/book/detail.js"></script>
</body>
</html>
}
```
---
## Summary: Key Changes from Original Carousel Dashboard Plan
### ✅ **What's Unchanged** (Phases 1-5):
- ✅ Database schema changes
- ✅ Service layer implementation
- ✅ Database queries
- ✅ Routes (frontend.go modifications)
- ✅ Template types (templates/types.go)
- ✅ Settings template structure
- ✅ SSR approach
- ✅ HTMX for library switching
### 🔧 **What's Changed** (Phase 6-7):
**1. Template HTML:**
- **Before:** `<button onclick="scrollCarousel('{ section.ID }', -1)">`
- **After:** `<button data-action="scroll-carousel" data-section-id="{ section.ID }" data-direction="-1">`
**2. TypeScript Files:**
- **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:**
**Before (inline handlers exported to window):**
```typescript
// ❌ Old pattern
(window as any).scrollCarousel = scrollCarousel;
(window as any).openDashboardSettings = openDashboardSettings;
```
**After (event delegation):**
```typescript
// ✅ New pattern (uses shared on() utility)
on('click', '[data-action="scroll-carousel"]', (target) => {
scrollCarousel(target.dataset.sectionId, parseInt(target.dataset.direction));
});
on('click', '[data-action="open-dashboard-settings"]', () => {
openDashboardSettings();
});
```
**4. API Calls:**
- **Before:** Raw `fetch('/settings', { method: 'POST', ... })`
- **After:** `await apiClient.post('/settings', data)`
**5. Type Definitions:**
- **Before:** Inline types defined in each file
- **After:** Import shared types or define in `web/ts/features/dashboard/types.ts` matching Go handlers
**6. Error Handling:**
- **Before:** `if (typeof showToast === 'function') { showToast(...) }`
- **After:** `import { showToast } from '../../core/toast.js'` and use `showToast.success()`, `showToast.error()`
---
## Execution Timeline
### **Recommended Order: TypeScript First**
| Phase | Duration | Dependencies | Deliverables |
|-------|----------|--------------|--------------|
| **TypeScript Conversion** | 16-21 days | None | All inline JS → TypeScript modules |
| **Dashboard Phase 1-5** | 8-10 hours | None | Backend (DB, service, routes, templates) |
| **Dashboard Phase 6-8** | 2-3 hours | TypeScript Conversion + Dashboard 1-5 | TypeScript + Templates |
| **Total** | **19-25 days** | | Complete TypeScript + Carousel Dashboard |
---
## Success Criteria
### Backend (Phases 1-3):
- [ ] Database schema updated and recreated
- [ ] Service layer implemented
- ] Database queries working
- [ ] Routes rendering SSR with data
- [ ] Templates types defined
- [ ] Bruno tests passing
### Frontend (Phases 4-6):
- [ ] Templates use `data-action` attributes (no inline onclick)
- [ ] TypeScript files in `web/ts/features/dashboard/` structure
- [ ] Event delegation properly configured
- [ ] Shared utilities imported and used
- [ ] API client used for all fetch calls
- [ ] Toast notifications integrated
- [ ] Progressive enhancement maintained
### Integration (Phase 7-8):
- [ ] Carousel scroll works (mouse and touch)
- [ ] Dashboard settings modal opens/closes
- [ ] Settings form saves correctly
- [ ] Theme switching works
- [ ] Drag-and-drop for section reordering
- [ ] Library switching via HTMX
- [ ] Book detail page displays correctly
---
*Updated: 2025-02-17*
*Prerequisites: TypeScript Conversion Plan must be completed first*
*Follows: PROJECT_GUIDELINES.md + TYPESCRIPT_CONVERSION_PLAN.md*
*Architecture: Hybrid SSR + TypeScript CRUD with Event Delegation*