# 🎬 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) { Settings - Bookhoard @Header(user, "/settings")

Settings

Profile

Appearance

Dashboard Preferences

{ fmt.Sprintf("%d", dashPrefs.ItemsPerSection) } items

Customize which sections appear on your dashboard by visiting the dashboard and clicking the settings icon.

} ``` --- ### **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) { Dashboard - Bookhoard @Header(user, "/dashboard")
for _, section := range sections { @SectionCarousel(section) }
@DashboardSettingsModal(sections) } ``` #### 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) {
{ section.Icon }

{ section.Title }

if section.Description != "" {

{ section.Description }

}
View All →
} templ BookCard(item BookCardData) {
if item.CoverImagePath != "" { { } else { { }

{ item.Title }

if item.Author != "" {

{ item.Author }

}
} templ DashboardSettingsModal(sections []SectionData) { } ``` #### 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; 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; 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 => { const sectionList = document.getElementById('section-list'); const items = sectionList?.querySelectorAll('.section-item') as NodeListOf; 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 => { 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 Settings - Bookhoard ``` **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) { { book.Title } - Bookhoard @Header(user, "")
if book.CoverImagePath.Valid { { } else { { }

{ book.Title }

if book.Author.Valid {

by { book.Author.String }

} if progress.Percentage > 0 {
Reading Progress { fmt.Sprintf("%.0f%%", progress.Percentage) }
}
{ book.Synopsis.Valid }
{ book.Synopsis.String }
{ end } if len(collections) > 0 {

Collections

for _, collection := range collections { { collection.Icon } { collection.Name } }
}
} ``` --- ## 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:** `