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

33 KiB

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:

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:

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:

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:

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:

// 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:

// 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:

// 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):

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:

// 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:

// 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:

    go build ./...
    
  2. Generate templ code:

    templ generate
    
  3. Build frontend:

    cd web && npm run build
    
  4. Start the application:

    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