feat: add book detail page with comprehensive metadata display

Implement SSR-first book detail page at /media/:uuid with complete
book information, progress tracking, and interactivity.

Features:
- Cover image (256x384px) with responsive layout
- Complete metadata: title, author, description, publisher, ISBN,
  language, edition, page count, genre, copyright year, format
- External service links (Goodreads, Open Library, Google Books, Amazon)
  with smart URL fallback: ID → ISBN → Title+Author
- Reading progress display with device sources (web/kobo/koreader)
- Sync progress modal for conflict resolution
- Collections display as clickable badges
- Notes/highlights counter with placeholder modal
- Rating display (1-10 scale with star rendering)
- HTML sanitization for book descriptions using bluemonday

Data Structure:
- handlers.MediaDetail embeds database.MediaItems for zero duplication
- Uses existing database queries (GetMediaItem, GetMediaRating, etc.)
- Follows project pattern: no parallel type systems

Frontend:
- TypeScript modal triggers (book-detail.ts)
- Alpine.js for modal interactions
- TailwindCSS styling with theme variables
- Responsive: cover-left layout, mobile stacks vertically

Backend:
- Route: GET /media/:uuid (protected)
- Handler: inline function in frontend.go following existing pattern
- Template: SSR-first with progressive enhancement
- Returns HTML only (API uses separate /api/media-items/:id endpoint)

Files created:
- internal/handlers/media_detail.go
- templates/book_detail.templ
- templates/book_detail_modals.templ
- web/src/book-detail.ts

Files modified:
- internal/router/frontend.go (add route)
- web/src/main.ts (import module)
This commit is contained in:
2026-03-28 23:54:39 -04:00
parent 1c52903172
commit eb349cbc95
8 changed files with 1867 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
package handlers
import "bookhoard/internal/database"
// 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"`
}
+114
View File
@@ -3,6 +3,7 @@ package router
import (
"bytes"
"context"
"encoding/json"
"log"
"net/http"
"strconv"
@@ -994,6 +995,119 @@ func registerFrontendRoutes(cfg *Config) {
return c.HTML(http.StatusOK, buf.String())
})
// 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())
})
e.GET("/health", cfg.GetHealth)
}