Convert string-interpolation attributes (value="{ x }") to templ
expression attributes (value={ x }) for IDs, paths, and titles, and
fix indentation in header.templ and progress.templ.
541 lines
20 KiB
Templ
541 lines
20 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 }" data-format-group={ book.FormatGroup } data-library-id={ uuidToString(book.LibraryID) }>
|
|
@Header(user, "/media/{ uuidToString(book.ID) }")
|
|
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
|
|
<div class="w-full px-4 py-3 flex items-center gap-4">
|
|
<a id="back-link" href="/dashboard" class="text-sm hover:opacity-80 transition-opacity" style="color: var(--text-secondary); text-decoration: none;">
|
|
← Back
|
|
</a>
|
|
</div>
|
|
</div>
|
|
<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="showMetadataEditor()"
|
|
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) + "&library_id=" + uuidToString(book.LibraryID) }
|
|
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>
|
|
<!-- Tags -->
|
|
if len(book.Tags) > 0 {
|
|
<div class="flex flex-wrap gap-2 mb-4">
|
|
for _, tag := range book.Tags {
|
|
<a
|
|
href={ "/tags/detail?name=" + url.QueryEscape(tag) + "&library_id=" + uuidToString(book.LibraryID) }
|
|
class="px-2 py-1 rounded-full text-xs font-semibold hover:opacity-80 transition-opacity"
|
|
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary); text-decoration: none;"
|
|
>
|
|
{ tag }
|
|
</a>
|
|
}
|
|
</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>
|
|
}
|
|
if len(book.Contributors) > 0 {
|
|
<div>
|
|
<p style="color: var(--text-secondary)">Contributors</p>
|
|
<p>{ strings.Join(book.Contributors, ", ") }</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)
|
|
@MetadataEditorModal(book)
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|