Create a new /series/detail?name=X&library_id=Y SSR page that shows all books in a specific series, replacing the broken approach of linking to /bookshelf?series_filter=X (the bookshelf SSR handler ignores all filter query params). The series detail page features: - Back link to /series browse page - Library selector dropdown (full page navigation on change) - Series name header with book count badge - Book grid using the shared BookCard template - Empty state for series with no books Update all links to point to the new page: - Series cards on /series browse page - Series badge on book detail page - JS-rendered cards in series.ts switchLibrary Add seriesDetailPage Alpine component for the detail page's library switcher (simple navigation, no AJAX needed).
513 lines
19 KiB
Templ
513 lines
19 KiB
Templ
package templates
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"fmt"
|
|
"net/url"
|
|
|
|
"github.com/microcosm-cc/bluemonday"
|
|
"strings"
|
|
)
|
|
|
|
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="xs:w-full md:mx-auto md:container 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={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
|
|
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 -->
|
|
<div class="mb-6">
|
|
<span class="text-2xl">
|
|
@templ.Raw(renderStars(getBookRating(book.Rating)))
|
|
</span>
|
|
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
|
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
|
|
</span>
|
|
</div>
|
|
<!-- Community Rating Display (from metadata) -->
|
|
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
|
|
<div class="mb-2">
|
|
<span class="text-lg" style="color: var(--text-secondary);">
|
|
Community Rating:
|
|
<span class="font-bold" style="color: var(--text-primary);">
|
|
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
|
|
</span>
|
|
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
|
|
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
|
|
</span>
|
|
</span>
|
|
</div>
|
|
}
|
|
<!-- Series Badge -->
|
|
if book.Series.Valid && book.Series.String != "" {
|
|
<div class="mb-4">
|
|
<a
|
|
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) }
|
|
class="px-3 py-1 rounded-full text-sm font-semibold inline-block hover:opacity-80 transition-opacity"
|
|
style="background-color: var(--accent); color: white; text-decoration: none;"
|
|
>
|
|
{ book.Series.String }
|
|
if book.SeriesNumber.Valid && book.SeriesNumber.Int32 > 0 {
|
|
#{ book.SeriesNumber.Int32 }
|
|
}
|
|
</a>
|
|
</div>
|
|
}
|
|
<!-- Reading Direction Badge (for manga/comics) -->
|
|
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
|
|
<div class="mb-4">
|
|
<span
|
|
class="px-3 py-1 rounded-full text-sm font-semibold"
|
|
style="background-color: var(--accent); color: white;"
|
|
>
|
|
📖 { strings.ToUpper(book.ReadingDirection.String) }
|
|
</span>
|
|
</div>
|
|
}
|
|
<!-- Comic-Specific Badges -->
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
<!-- Age Rating Badge -->
|
|
if book.AgeRating.Valid && book.AgeRating.String != "" {
|
|
<span
|
|
class="px-2 py-1 rounded-full text-xs font-semibold"
|
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
|
>
|
|
{ book.AgeRating.String }
|
|
</span>
|
|
}
|
|
<!-- Black and White Badge -->
|
|
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
|
|
<span
|
|
class="px-2 py-1 rounded-full text-xs font-semibold"
|
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
|
>
|
|
B&W
|
|
</span>
|
|
}
|
|
<!-- Story Arc Badge -->
|
|
if book.StoryArc.Valid && book.StoryArc.String != "" {
|
|
<span
|
|
class="px-2 py-1 rounded-full text-xs font-semibold"
|
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
|
|
>
|
|
📚 { book.StoryArc.String }
|
|
</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>
|
|
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
|
@UnsafeHTML(
|
|
bluemonday.UGCPolicy().Sanitize(book.Description.String),
|
|
).ToComponent()
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
</div>
|
|
<!-- Summary (from ComicInfo.xml, if different from description) -->
|
|
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
|
|
<div class="mb-6">
|
|
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">Comic Summary</h3>
|
|
<div class="max-h-[20rem] overflow-y-auto pr-2" style="color: var(--text-secondary)">
|
|
@UnsafeHTML(
|
|
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
|
|
).ToComponent()
|
|
</div>
|
|
</div>
|
|
}
|
|
<!-- Metadata Notes (technical notes from metadata files) -->
|
|
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
|
|
<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-2" style="color: var(--text-primary)">Metadata Notes</h3>
|
|
<div class="text-sm" style="color: var(--text-secondary);">
|
|
{ book.MetadataNotes.String }
|
|
</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={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
|
|
></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 * 100) }%
|
|
</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>{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</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("01-02-2006") }</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>
|
|
}
|
|
<!-- Series Information (if not shown in badge) -->
|
|
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Series Count</p>
|
|
<p>{ book.SeriesCount.Int32 } items</p>
|
|
</div>
|
|
}
|
|
if book.Volume.Valid && book.Volume.Int32 > 0 {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Volume</p>
|
|
<p>Vol. { book.Volume.Int32 }</p>
|
|
</div>
|
|
}
|
|
if book.Imprint.Valid && book.Imprint.String != "" {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Imprint</p>
|
|
<p>{ book.Imprint.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>
|
|
}
|
|
<!-- Comic-Specific Fields -->
|
|
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Manga Type</p>
|
|
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
|
|
</div>
|
|
}
|
|
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Scan Info</p>
|
|
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
|
|
</div>
|
|
}
|
|
if len(book.AlternateInfo) > 0 {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Alternate Series</p>
|
|
<p class="text-sm">{ string(book.AlternateInfo) }</p>
|
|
</div>
|
|
}
|
|
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Alternate Series</p>
|
|
<p>{ altSeries }</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 || book.WebUrl.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>
|
|
}
|
|
if book.WebUrl.Valid && book.WebUrl.String != "" {
|
|
<a
|
|
href={ book.WebUrl.String }
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="text-sm hover:underline flex items-center gap-1"
|
|
style="color: var(--accent);"
|
|
>
|
|
🔗 { getDomainName(book.WebUrl.String) }
|
|
</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(user, book)
|
|
@NotesHighlightsModal(book)
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|