Files
bookhoard/BOOK_DETAIL_IMPLEMENTATION.md
T
john-okeefe 542940c2ff docs: add book detail page implementation guide
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.
2026-03-28 22:03:01 -04:00

1077 lines
33 KiB
Markdown

# 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) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ book.Title } - Bookhoard</title>
<script src="/static/htmx.min.js"></script>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="bookDetail" class="theme-{ user.Theme }">
@Header(user, "/media/{ uuidToString(book.ID) }")
<div class="w-full px-4 sm:px-6 lg:px-8 py-8">
<!-- Top Section: Cover + Basic Info + Actions -->
<div class="flex flex-col md:flex-row gap-8 mb-8">
<!-- Left: Cover Image (256x384px) -->
<div class="flex-shrink-0">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img src="{ book.CoverImagePath.String }"
alt="{ book.Title }"
class="w-64 h-96 object-cover rounded-lg shadow-xl"
onerror="this.src='/static/placeholder-book.svg'"/>
} else {
<img src="/static/placeholder-book.svg"
alt="{ book.Title }"
class="w-64 h-96 object-cover rounded-lg shadow-xl"/>
}
</div>
<!-- Right: Details -->
<div class="flex-1">
<!-- Title & Author -->
<h1 class="text-4xl font-bold mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
if book.Author.Valid && book.Author.String != "" {
<h2 class="text-2xl mb-6" style="color: var(--text-secondary)">
by { book.Author.String }
</h2>
}
<!-- Action Buttons -->
<div class="flex flex-wrap gap-3 mb-6">
<!-- Read Now (placeholder) -->
<button @click="showReaderPlaceholder()"
class="px-6 py-3 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;">
📖 Read Now
</button>
<!-- Sync Progress -->
if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button @click="showProgressSyncModal()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
🔄 Sync Progress
</button>
}
<!-- View Notes/Highlights -->
<button @click="showNotesModal()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
📝 Notes & Highlights
if book.NotesCount + book.HighlightsCount > 0 {
<span class="ml-2 px-2 py-0.5 rounded text-xs font-semibold"
style="background-color: var(--accent); color: white;">
{ book.NotesCount + book.HighlightsCount }
</span>
}
</button>
<!-- Edit Metadata (placeholder) -->
<button @click="showMetadataEditorPlaceholder()"
class="px-6 py-3 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary); background-color: var(--bg-secondary);">
✏️ Edit Metadata
</button>
</div>
<!-- Rating Display -->
if book.Rating != nil {
<div class="mb-6">
<span class="text-yellow-400 text-2xl">
{ renderStars(book.Rating.Rating) }
</span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
({ fmt.Sprintf("%.1f", float64(book.Rating.Rating)/2.0) } / 5)
</span>
</div>
}
<!-- Series Badge -->
if book.Series.Valid && book.Series.String != "" {
<div class="mb-4">
<span class="px-3 py-1 rounded-full text-sm font-semibold"
style="background-color: var(--accent); color: white;">
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
#{ book.SeriesNumber.Int32 }
}
</span>
</div>
}
<!-- Description/Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Synopsis</h3>
<p style="color: var(--text-secondary)">{ book.Description.String }</p>
</div>
}
</div>
</div>
<!-- Progress Section -->
if book.ReadingProgress != nil {
<div class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Reading Progress</h3>
if book.ActiveConflict != nil {
<div class="mb-4 p-3 rounded-lg border"
style="background-color: #f59e0b20; border-color: #f59e0b;">
<p style="color: var(--text-primary);">
⚠️ Progress conflict detected - Click "Sync Progress" to review and resolve
</p>
</div>
}
<!-- Progress Bar -->
<div class="mb-4">
<div class="w-full rounded-full h-3" style="background-color: var(--bg-primary);">
<div class="h-3 rounded-full transition-all"
style="width: { book.ReadingProgress.Percentage.Float64 }%; background-color: var(--accent);"></div>
</div>
</div>
<!-- Progress Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<p style="color: var(--text-secondary)">Progress</p>
<p class="text-2xl font-bold" style="color: var(--accent);">
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64) }%
</p>
</div>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<div>
<p style="color: var(--text-secondary)">Page</p>
<p>{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div>
}
if book.ReadingProgress.LastReadAt.Valid {
<div>
<p style="color: var(--text-secondary)">Last Read</p>
<p>{ book.ReadingProgress.LastReadAt.Time.Format("2006-01-02 15:04") }</p>
</div>
}
if book.ReadingProgress.LastSyncSource.Valid {
<div>
<p style="color: var(--text-secondary)">Source</p>
<p class="capitalize">{ book.ReadingProgress.LastSyncSource.String }</p>
</div>
}
</div>
</div>
}
<!-- Metadata Grid -->
<div class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Metadata</h3>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<!-- Publication Info -->
if book.Publisher.Valid && book.Publisher.String != "" {
<div>
<p style="color: var(--text-secondary)">Publisher</p>
<p>{ book.Publisher.String }</p>
</div>
}
if book.DatePublished.Valid {
<div>
<p style="color: var(--text-secondary)">Published</p>
<p>{ book.DatePublished.Time.Format("2006-01-02") }</p>
</div>
}
if book.ISBN.Valid && book.ISBN.String != "" {
<div>
<p style="color: var(--text-secondary)">ISBN</p>
<p>{ book.ISBN.String }</p>
</div>
}
if book.Language.Valid && book.Language.String != "" {
<div>
<p style="color: var(--text-secondary)">Language</p>
<p class="capitalize">{ book.Language.String }</p>
</div>
}
if book.Edition.Valid && book.Edition.String != "" {
<div>
<p style="color: var(--text-secondary)">Edition</p>
<p>{ book.Edition.String }</p>
</div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Pages</p>
<p>{ book.PageCount.Int32 }</p>
</div>
}
if book.Genre.Valid && book.Genre.String != "" {
<div>
<p style="color: var(--text-secondary)">Genre</p>
<p>{ book.Genre.String }</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Copyright Year</p>
<p>{ book.CopyrightYear.Int32 }</p>
</div>
}
<!-- Technical Info -->
<div>
<p style="color: var(--text-secondary)">Format</p>
<p>{ book.MimeType.String }</p>
</div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div>
<p style="color: var(--text-secondary)">File Size</p>
<p>{ formatFileSize(book.FileSize.Int64) }</p>
</div>
}
</div>
<!-- External IDs Section -->
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.ASIN.Valid || book.ISBN.Valid {
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
<div class="flex flex-wrap gap-3">
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" {
<a href={ getExternalURL("goodreads", book.GoodreadsID.String, book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
📚 Goodreads
</a>
} else {
<a href={ getExternalURL("goodreads", "", book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
📚 Goodreads
</a>
}
if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" {
<a href={ getExternalURL("openlibrary", book.OpenlibraryID.String, book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
📖 Open Library
</a>
} else {
<a href={ getExternalURL("openlibrary", "", book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
📖 Open Library
</a>
}
if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" {
<a href={ getExternalURL("googlebooks", book.GoogleBooksID.String, book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
🔍 Google Books
</a>
} else {
<a href={ getExternalURL("googlebooks", "", book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
🔍 Google Books
</a>
}
if book.ASIN.Valid && book.ASIN.String != "" {
<a href={ getExternalURL("amazon", book.ASIN.String, book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
🛒 Amazon
</a>
} else if book.ISBN.Valid && book.ISBN.String != "" {
<a href={ getExternalURL("amazon", "", book.ISBN, book.Title, book.Author) }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);">
🛒 Amazon
</a>
}
</div>
</div>
}
</div>
<!-- Collections Section -->
if len(book.Collections) > 0 {
<div class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-3">
for _, col := range book.Collections {
<a href={ "/collections/" + uuidToString(col.ID) }
class="px-3 py-2 rounded-lg border flex items-center gap-2 hover:opacity-80 transition-opacity"
style="border-color: { col.Color.String }; background-color: var(--bg-primary); text-decoration: none;">
<span style="color: { col.Color.String };">{ col.Icon.String }</span>
<span style="color: var(--text-primary);">{ col.Name }</span>
</a>
}
</div>
</div>
}
</div>
<!-- Modals -->
@ProgressSyncModal(book)
@NotesHighlightsModal(book)
@ErrorToast(errorMessage)
</body>
</html>
}
```
---
### 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) {
<div id="progress-sync-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center overflow-y-auto"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-6 w-full max-w-4xl mx-4 my-8"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="flex justify-between items-center mb-6">
<div>
<h2 class="text-2xl font-bold" style="color: var(--text-primary)">Sync Progress</h2>
<p class="text-sm" style="color: var(--text-secondary);">{ book.Title }</p>
</div>
<button @click="hideProgressSyncModal()"
class="p-2 hover:opacity-80 rounded-lg"
style="color: var(--text-primary); background-color: var(--bg-primary);">
</button>
</div>
if book.ActiveConflict != nil {
<!-- Conflict Detected - Show All Sources -->
<div class="mb-6 p-4 rounded-lg border"
style="background-color: #f59e0b20; border-color: #f59e0b;">
<p class="font-semibold mb-2" style="color: var(--text-primary);">
⚠️ Conflict Detected
</p>
<p class="text-sm" style="color: var(--text-secondary);">
Progress differs between devices. Review the options below and manually resolve via the Conflicts page.
</p>
<a href="/conflicts"
class="inline-block mt-3 px-4 py-2 rounded-lg text-sm font-semibold"
style="background-color: #f59e0b; color: white; text-decoration: none;">
Go to Conflicts Page
</a>
</div>
<!-- Display all device progress side-by-side -->
<div class="space-y-4">
for source, data := range book.ActiveConflict.ConflictData {
<div class="card p-4 rounded-lg border"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="flex justify-between items-start mb-3">
<div>
<span class="inline-block px-2 py-1 rounded text-xs font-semibold capitalize mb-2"
style="background-color: var(--accent); color: white;">
{ source }
</span>
<p class="text-xs" style="color: var(--text-secondary);">
{ data.Timestamp.Format("2006-01-02 15:04:05") }
</p>
</div>
<div class="text-right">
<div class="text-3xl font-bold" style="color: var(--accent);">
{ fmt.Sprintf("%.1f", data.Data["percentage"].(float64)) }%
</div>
</div>
</div>
<!-- Additional progress details if available -->
if data.Data["current_page"] != nil {
<div class="text-sm" style="color: var(--text-secondary);">
Page: { fmt.Sprintf("%.0f", data.Data["current_page"].(float64)) } / { fmt.Sprintf("%.0f", data.Data["total_pages"].(float64)) }
</div>
}
if data.Data["epubcfi"] != nil {
<div class="text-xs mt-1" style="color: var(--text-secondary);">
CFI: { data.Data["epubcfi"].(string) }
</div>
}
</div>
}
</div>
} else if book.ReadingProgress != nil {
<!-- No Conflict - Show Current Progress -->
<div class="mb-4" style="color: var(--text-secondary);">
<p>No conflicts detected. Current progress from <strong>{ book.ReadingProgress.LastSyncSource.String }</strong>:</p>
</div>
<div class="card p-6 rounded-lg border text-center"
style="background-color: var(--bg-primary); border-color: var(--border);">
<div class="text-5xl font-bold mb-4" style="color: var(--accent);">
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64) }%
</div>
<p class="capitalize text-lg mb-2" style="color: var(--text-primary);">
{ book.ReadingProgress.LastSyncSource.String }
</p>
if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {
<p style="color: var(--text-secondary);">
Page { book.ReadingProgress.CurrentPage.Int32 } of { book.ReadingProgress.TotalPages.Int32 }
</p>
}
if book.ReadingProgress.LastReadAt.Valid {
<p class="text-sm mt-2" style="color: var(--text-secondary);">
Last read: { book.ReadingProgress.LastReadAt.Time.Format("2006-01-02 15:04") }
</p>
}
</div>
}
<div class="flex justify-end space-x-3 mt-6">
<button @click="hideProgressSyncModal()"
class="px-4 py-2 rounded-lg"
style="background-color: var(--bg-primary); color: var(--text-primary); border: 1px solid var(--border);">
Close
</button>
</div>
</div>
</div>
}
// NotesHighlightsModal - Placeholder for future feature
templ NotesHighlightsModal(book handlers.MediaDetail) {
<div id="notes-modal" class="hidden fixed inset-0 z-50 flex items-center justify-center"
style="background-color: rgba(0, 0, 0, 0.7);">
<div class="card rounded-lg p-8 w-full max-w-2xl mx-4 text-center"
style="background-color: var(--bg-secondary); border-color: var(--border);">
<div class="text-6xl mb-4">📝</div>
<h2 class="text-2xl font-bold mb-2" style="color: var(--text-primary)">Notes & Highlights</h2>
<p class="mb-2" style="color: var(--text-secondary);">
This book has <strong>{ book.NotesCount }</strong> notes and <strong>{ book.HighlightsCount }</strong> highlights.
</p>
<p class="mb-6" style="color: var(--text-secondary);">Feature coming soon!</p>
<div>
<button @click="hideNotesModal()"
class="px-6 py-2 rounded-lg font-semibold"
style="background-color: var(--accent); color: white;">
Close
</button>
</div>
</div>
</div>
}
```
---
### 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