From 542940c2ffe7c8d0cf5d6d20d54a8ff07a5227ff Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Sat, 28 Mar 2026 22:03:01 -0400 Subject: [PATCH] docs: add book detail page implementation guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add comprehensive implementation guide for /media/:uuid book detail page. Features documented: - SSR-first template with Alpine.js for modals - Cover image (left) + metadata (right) layout - Reading progress tracking with conflict detection - Sync progress modal (comparison only, manual resolution via /conflicts) - Notes & highlights counter with placeholder modal - Collections display as clickable badges - External service links (Goodreads, Open Library, Google Books, Amazon) - Smart URL fallback: ID → ISBN → Title+Author search Technical approach: - Embeds database.MediaItems struct for zero duplication - Uses existing database queries (GetMediaItem, GetMediaRating, etc.) - Follows existing pattern: inline handlers in router/frontend.go - Keeps json tags in struct for API endpoint compatibility - Separate routes: /media/:uuid (HTML) vs /api/media-items/:id (JSON) Files to create: - internal/handlers/media_detail.go (data structure) - templates/book_detail.templ (SSR template) - templates/book_detail_modals.templ (modals) - web/src/book-detail.ts (Alpine.js integration) Files to modify: - internal/router/frontend.go (add route) - web/src/main.ts (import module) - templates/utils.go (helper functions) See BOOK_DETAIL_IMPLEMENTATION.md for complete implementation details. --- BOOK_DETAIL_IMPLEMENTATION.md | 1076 +++++++++++++++++++++++++++++++++ 1 file changed, 1076 insertions(+) create mode 100644 BOOK_DETAIL_IMPLEMENTATION.md diff --git a/BOOK_DETAIL_IMPLEMENTATION.md b/BOOK_DETAIL_IMPLEMENTATION.md new file mode 100644 index 0000000..d25bc99 --- /dev/null +++ b/BOOK_DETAIL_IMPLEMENTATION.md @@ -0,0 +1,1076 @@ +# 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