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:
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user