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.
1077 lines
33 KiB
Markdown
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
|