# Book Detail Page Implementation Guide Complete implementation guide for the `/media/:uuid` book detail page. ## ✅ Verified Backend Code All database queries, function signatures, and struct definitions used in this guide have been verified against the actual codebase: - `GetMediaItem` ✓ (line 179 in querier.go) - `GetMediaRating` ✓ (line 201 in querier.go) - `GetCollectionsForBook` ✓ (line 132 in querier.go) - `GetReadingProgress` ✓ (line 213 in querier.go) - `ListSyncConflictsByMediaItem` ✓ (line 267 in querier.go) - `GetMediaNotes` ✓ (line 200 in querier.go) - `GetMediaHighlights` ✓ (line 178 in querier.go) - `database.MediaItems` struct ✓ (lines 182-235 in models.go) - `database.Collections` struct ✓ (lines 20-34 in models.go) - `database.MediaRatings` struct ✓ (lines 254-262 in models.go) - `database.ReadingProgress` struct ✓ (lines 287-312 in models.go) ## Overview - **Route**: `GET /media/:uuid` - **Template**: SSR-first with Alpine.js for modal interactions - **Data Structure**: Embeds `database.MediaItems` to avoid duplication - **Features**: Cover + metadata, progress tracking, sync modal, collections, placeholder buttons --- ## Files to Create ### 1. MediaDetail Data Structure **Location**: `internal/handlers/media_detail.go` (new file) **Full file content**: ```go package handlers import ( "bookhoard/internal/database" "encoding/json" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) // MediaDetail embeds database.MediaItems for complete book metadata // No field duplication - template gets direct access to all database fields type MediaDetail struct { database.MediaItems // Embedded - ALL book fields available // User-specific data Rating *database.MediaRatings `json:"rating,omitempty"` Collections []database.Collections `json:"collections"` ReadingProgress *database.ReadingProgress `json:"reading_progress,omitempty"` // Conflict data (if exists) ActiveConflict *ConflictDetailResponse `json:"active_conflict,omitempty"` // Computed counts NotesCount int `json:"notes_count"` HighlightsCount int `json:"highlights_count"} } ``` **Note**: The actual handler is implemented in `internal/router/frontend.go` as an inline function (see step 5), following the pattern used by all other frontend routes in this codebase. --- ### 2. `templates/book_detail.templ` **Location**: `templates/book_detail.templ` (new file) **Full file content**: ```templ package templates import "bookhoard/internal/handlers" templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) { { book.Title } - Bookhoard @Header(user, "/media/{ uuidToString(book.ID) }")
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { { book.Title } } else { { book.Title } }

{ book.Title }

if book.Author.Valid && book.Author.String != "" {

by { book.Author.String }

}
if book.ActiveConflict != nil || book.ReadingProgress != nil { }
if book.Rating != nil {
{ renderStars(book.Rating.Rating) } ({ fmt.Sprintf("%.1f", float64(book.Rating.Rating)/2.0) } / 5)
} if book.Series.Valid && book.Series.String != "" {
{ book.Series.String } if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 { #{ book.SeriesNumber.Int32 } }
} if book.Description.Valid && book.Description.String != "" {

Synopsis

{ book.Description.String }

}
if book.ReadingProgress != nil {

Reading Progress

if book.ActiveConflict != nil {

⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve

}

Progress

{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64) }%

if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {

Page

{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }

} if book.ReadingProgress.LastReadAt.Valid {

Last Read

{ book.ReadingProgress.LastReadAt.Time.Format("2006-01-02 15:04") }

} if book.ReadingProgress.LastSyncSource.Valid {

Source

{ book.ReadingProgress.LastSyncSource.String }

}
}

Metadata

if book.Publisher.Valid && book.Publisher.String != "" {

Publisher

{ book.Publisher.String }

} if book.DatePublished.Valid {

Published

{ book.DatePublished.Time.Format("2006-01-02") }

} if book.ISBN.Valid && book.ISBN.String != "" {

ISBN

{ book.ISBN.String }

} if book.Language.Valid && book.Language.String != "" {

Language

{ book.Language.String }

} if book.Edition.Valid && book.Edition.String != "" {

Edition

{ book.Edition.String }

} if book.PageCount.Valid && book.PageCount.Int32 > 0 {

Pages

{ book.PageCount.Int32 }

} if book.Genre.Valid && book.Genre.String != "" {

Genre

{ book.Genre.String }

} if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {

Copyright Year

{ book.CopyrightYear.Int32 }

}

Format

{ book.MimeType.String }

if book.FileSize.Valid && book.FileSize.Int64 > 0 {

File Size

{ formatFileSize(book.FileSize.Int64) }

}
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.ASIN.Valid || book.ISBN.Valid {

External Links

if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { 📚 Goodreads } else { 📚 Goodreads } if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" { 📖 Open Library } else { 📖 Open Library } if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" { 🔍 Google Books } else { 🔍 Google Books } if book.ASIN.Valid && book.ASIN.String != "" { 🛒 Amazon } else if book.ISBN.Valid && book.ISBN.String != "" { 🛒 Amazon }
}
if len(book.Collections) > 0 {

Collections

for _, col := range book.Collections { { col.Icon.String } { col.Name } }
}
@ProgressSyncModal(book) @NotesHighlightsModal(book) @ErrorToast(errorMessage) } ``` --- ### 3. `templates/book_detail_modals.templ` **Location**: `templates/book_detail_modals.templ` (new file) **Full file content**: ```templ package templates import "bookhoard/internal/handlers" // ProgressSyncModal shows progress from all devices for manual review templ ProgressSyncModal(book handlers.MediaDetail) { } // NotesHighlightsModal - Placeholder for future feature templ NotesHighlightsModal(book handlers.MediaDetail) { } ``` --- ### 4. `web/src/book-detail.ts` **Location**: `web/src/book-detail.ts` (new file) **Full file content**: ```typescript import { Alpine } from "./alpine"; import { showToast } from "./toast"; function showReaderPlaceholder(): void { showToast("Ebook reader coming soon!", "info"); } function showMetadataEditorPlaceholder(): void { showToast("Metadata editor coming soon!", "info"); } function showProgressSyncModal(): void { const modal = document.getElementById("progress-sync-modal"); if (modal) { modal.classList.remove("hidden"); } } function showNotesModal(): void { const modal = document.getElementById("notes-modal"); if (modal) { modal.classList.remove("hidden"); } } function hideProgressSyncModal(): void { const modal = document.getElementById("progress-sync-modal"); if (modal) { modal.classList.add("hidden"); } } function hideNotesModal(): void { const modal = document.getElementById("notes-modal"); if (modal) { modal.classList.add("hidden"); } } export { showReaderPlaceholder, showMetadataEditorPlaceholder, showProgressSyncModal, showNotesModal, hideProgressSyncModal, hideNotesModal, }; Alpine.data("bookDetail", () => ({ showReaderPlaceholder, showMetadataEditorPlaceholder, showProgressSyncModal, showNotesModal, hideProgressSyncModal, hideNotesModal, })); ``` --- ## Files to Modify ### 5. `internal/router/frontend.go` - Add Route and Import **Location**: `internal/router/frontend.go` #### Part A: Add Import **Find**: Line 19-21 (import section) **Add**: `encoding/json` import if not present **Surgical edit**: ```go // Around lines 1-22: package router import ( "bytes" "context" "encoding/json" // ADD if not present "log" "net/http" // ... rest of imports ... ``` #### Part B: Add Route Handler **Find**: Around line 970-980, after conflicts-page route, before devices-page route **Add**: Inline handler for book detail page **Surgical edit**: ```go // Around line 970-980 (after conflicts-page route): frontendProtected.GET("/conflicts-page", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } var errorMsg string var conflictsData []handlers.ConflictDetailResponse var total, unresolved int conflictsData, total, unresolved, err = cfg.ConflictHandler.GetConflictsData(c) // ... rest of conflicts handler ... }) // ADD THESE LINES: // Book detail page frontendProtected.GET("/media/:uuid", func(c *echo.Context) error { user, err := getTemplateUserWithTheme(c, cfg) if err != nil { return renderErrorPage(c, "Error loading user", "user_load_error") } // Parse media UUID from URL mediaUUID, err := uuid.Parse(c.Param("uuid")) if err != nil { return renderErrorPage(c, "Invalid media ID", "invalid_id") } pgMediaUUID := uuidToPGType(mediaUUID) // Get user UUID for queries userUUID, _ := uuid.Parse(user.ID) pgUserID := uuidToPGType(userUUID) // Fetch media item (embeds ALL metadata) mediaItem, err := cfg.Queries.GetMediaItem(c.Request().Context(), pgMediaUUID) if err != nil { if err.Error() == "no rows in result set" { return renderErrorPage(c, "Book not found", "not_found") } return renderErrorPage(c, "Error loading book", "database_error") } // Resolve cover image path if mediaItem.CoverImagePath.Valid && mediaItem.CoverImagePath.String != "" { resolvedPath := utils.ResolveMediaURL(mediaItem.LibraryID, mediaItem.CoverImagePath) mediaItem.CoverImagePath = pgtype.Text{String: resolvedPath, Valid: true} } // Fetch rating var rating *database.MediaRatings userRating, err := cfg.Queries.GetMediaRating(c.Request().Context(), database.GetMediaRatingParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, }) if err == nil { rating = &userRating } // Fetch collections collections, _ := cfg.Queries.GetCollectionsForBook(c.Request().Context(), pgMediaUUID) // Fetch reading progress var progress *database.ReadingProgress readingProgress, err := cfg.Queries.GetReadingProgress(c.Request().Context(), database.GetReadingProgressParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, }) if err == nil { progress = &readingProgress } // Fetch active conflict (if any) var activeConflict *handlers.ConflictDetailResponse conflicts, err := cfg.Queries.ListSyncConflictsByMediaItem(c.Request().Context(), database.ListSyncConflictsByMediaItemParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, }) if err == nil && len(conflicts) > 0 { for _, conf := range conflicts { if conf.ResolutionStatus.Valid && conf.ResolutionStatus.String == "unresolved" { var conflictData map[string]handlers.ConflictSourceData if err := json.Unmarshal(conf.ConflictData, &conflictData); err == nil { activeConflict = &handlers.ConflictDetailResponse{ ID: uuid.UUID(conf.ID.Bytes).String(), MediaItemID: uuid.UUID(conf.MediaItemID.Bytes).String(), MediaItemTitle: mediaItem.Title, ConflictType: conf.ConflictType, ConflictData: conflictData, ResolutionStatus: conf.ResolutionStatus.String, CreatedAt: conf.CreatedAt.Time, } } break } } } // Count notes and highlights notes, _ := cfg.Queries.GetMediaNotes(c.Request().Context(), database.GetMediaNotesParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, }) highlights, _ := cfg.Queries.GetMediaHighlights(c.Request().Context(), database.GetMediaHighlightsParams{ MediaItemID: pgMediaUUID, UserID: pgUserID, }) // Assemble response (no field duplication!) detail := handlers.MediaDetail{ MediaItems: mediaItem, // Embedded - ALL fields available Rating: rating, Collections: collections, ReadingProgress: progress, ActiveConflict: activeConflict, NotesCount: len(notes), HighlightsCount: len(highlights), } // Render template var buf bytes.Buffer err = templates.BookDetail(user, detail, "").Render(c.Request().Context(), &buf) if err != nil { return err } return c.HTML(http.StatusOK, buf.String()) }) // END ADD // Devices page frontendProtected.GET("/devices-page", func(c *echo.Context) error { // ... existing devices handler starts around line 929 ... }) ``` --- ### 6. `web/src/main.ts` - Import Book Detail Module **Location**: `web/src/main.ts` **Find**: Around line 30, the import section **Add after**: `import "./bookshelf";` line (around line 10-15) **Surgical edit**: ```go // Around lines 10-30 in the import section: import "./analytics"; import "./api"; // ... existing imports ... import "./bookshelf"; // ADD THIS LINE: import "./book-detail"; // END ADD import "./collection-rules"; // ... rest of imports ... ``` **Full context (lines 1-40)**: ```typescript import "./alpine"; import { Alpine } from "./alpine"; import "./admin"; import "./analytics"; import "./api"; import "./api-explorer"; import "./api-explorer-docs"; import "./bookPicker"; import "./bookshelf"; // ADD THIS LINE: import "./book-detail"; // END ADD import "./collection-rules"; import "./collections"; import "./conflicts"; import "./custom-section-builder"; import "./dashboard"; import "./device-management"; import "./docs"; // ... rest of file ... ``` --- ### 7. `templates/utils.go` - Add Helper Functions **Location**: `templates/utils.go` **Find**: End of file (after existing helper functions) **Add**: New helper functions at the end **Surgical edit**: ```go // At the end of templates/utils.go (after uuidToString function, before closing brace): // ADD THESE FUNCTIONS: // renderStars converts rating (1-10 scale) to star display // Rating scale: 1-10 where odd numbers = half stars (1=0.5★, 3=1.5★, etc.) func renderStars(rating int32) string { stars := "" fullStars := rating / 2 hasHalf := rating % 2 != 0 for i := int32(0); i < fullStars; i++ { stars += "★" } if hasHalf { stars += "½" } return stars } // formatFileSize converts bytes to human-readable format func formatFileSize(bytes int64) string { const ( KB = 1024 MB = KB * 1024 GB = MB * 1024 ) switch { case bytes >= GB: return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB)) case bytes >= MB: return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB)) case bytes >= KB: return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB)) default: return fmt.Sprintf("%d B", bytes) } } // getExternalURL generates URL for external book services // Priority: ID > ISBN > Title+Author search func getExternalURL(service string, id string, isbn pgtype.Text, title string, author pgtype.Text) string { baseURL := "" searchTerm := "" // Determine search term: ID > ISBN > Title+Author if id != "" { searchTerm = id } else if isbn.Valid && isbn.String != "" { searchTerm = isbn.String } else { // Build title+author search query if author.Valid && author.String != "" { searchTerm = fmt.Sprintf("%s %s", title, author.String) } else { searchTerm = title } } // Build URL based on service switch service { case "goodreads": if id != "" { baseURL = "https://www.goodreads.com/book/show/" } else { baseURL = "https://www.goodreads.com/search?q=" } case "openlibrary": if id != "" { baseURL = "https://openlibrary.org/books/" } else { baseURL = "https://openlibrary.org/search?q=" } case "googlebooks": if id != "" { baseURL = "https://books.google.com/books?id=" } else { baseURL = "https://www.google.com/search?tbm=bks&q=" } case "amazon": // Amazon doesn't have direct book IDs, always search baseURL = "https://www.amazon.com/s?k=" if isbn.Valid && isbn.String != "" { searchTerm = isbn.String } } return baseURL + searchTerm } // END ADD ``` **Part B: Verify/Update imports** **Find**: Top of `templates/utils.go` (lines 1-10) **Check if these imports exist, add if missing**: ```go // At the top of templates/utils.go: package templates import ( "fmt" // Ensure this is present "github.com/google/uuid" // Should already be present "github.com/jackc/pgx/v5/pgtype" // ADD if not present // ... other existing imports ... ) ``` --- ## Verification Steps After implementing all changes: 1. **Compile check**: ```bash go build ./... ``` 2. **Generate templ code**: ```bash templ generate ``` 3. **Build frontend**: ```bash cd web && npm run build ``` 4. **Start the application**: ```bash podman compose up -d ``` 5. **Test the page**: - Navigate to any book: `http://localhost:8080/media/{uuid}` - Test with a book that has: - Cover image - Rating - Collections - Reading progress - Notes/highlights - External IDs --- ## Testing Checklist - [ ] Page loads without errors - [ ] Cover image displays correctly (fallback to placeholder) - [ ] All metadata fields display when present - [ ] External links work (Goodreads, Open Library, Google Books, Amazon) - [ ] Progress section displays correctly - [ ] Sync Progress modal opens and shows device progress - [ ] Notes & Highlights modal opens with placeholder message - [ ] "Read Now" button shows toast - [ ] "Edit Metadata" button shows toast - [ ] Collections display as clickable badges - [ ] Mobile responsive (stacks vertically) - [ ] Theme switching works - [ ] No console errors --- ## Future Enhancements (Out of Scope) 1. **Ebook Reader**: Integrate web-based EPUB/PDF reader 2. **Metadata Editor**: Form to edit book metadata with API endpoint 3. **Notes/Highlights Viewer**: Display all notes and highlights in modal 4. **Progress Resolution**: Allow resolving conflicts directly from modal (reuse conflicts page logic) 5. **Related Books**: Show other books in same series or by same author 6. **Reading Statistics**: Show reading history for this book --- ## Notes - **No Database Migrations Required**: Uses existing database schema - **No New API Endpoints**: Uses existing database queries - **Type Safety**: Leverages sqlc-generated `database.MediaItems` struct - **Progress Sync**: Modal shows comparison only - resolution via existing `/conflicts` page - **External Links**: Smart fallback from ID → ISBN → title+author search - **Responsive Design**: Mobile-first with TailwindCSS breakpoints