Files
bookhoard/templates/book_detail.templ
T
John O'Keefe ff17d14694 fix(book-detail): hero backdrop — templ double-escapes style values
templ's codegen wraps style attribute expressions in a sanitizer AND
templ.EscapeString, so any quote character — even inside templ.SafeCSS
— reached the DOM as literal ' text. The hero backdrop's url('...')
never parsed: no blurred cover on any book detail page, and Firefox
resolved the mangled token as a relative URL, fetching /media/& (500)
once per page load.

Fix: emit quote-free CSS — an unquoted url(...) token built through a
new cssURL() helper that percent-encodes the characters that would
break it (space, quotes, parens, backslash). Stored cover paths are
already percent-encoded URL paths, so the escaping is defensive only.
2026-10-21 20:21:33 -04:00

624 lines
31 KiB
Templ

package templates
import (
"bookhoard/internal/handlers"
"fmt"
"net/url"
// NOTE: do not import "github.com/a-h/templ" here — templ's generator
// already emits that import into the generated file, and a second copy
// is a compile error (redeclared block).
"github.com/microcosm-cc/bluemonday"
"strings"
)
// cssURL neutralizes characters that would break an UNQUOTED CSS url()
// token (whitespace, quotes, parens, backslash). The stored cover paths
// are already percent-encoded URL paths, so this is defensive only — but
// it must exist: templ's codegen HTML-escapes style attribute values a
// second time, so ANY quote character (SafeCSS included) reaches the DOM
// as literal ' text and the browser never parses the url() at all
// (2026-10-21: every hero backdrop broken + /media/& 500 spam, because
// Firefox resolved the mangled token relative to the page URL).
func cssURL(s string) string {
return strings.NewReplacer(
" ", "%20",
"(", "%28",
")", "%29",
"'", "%27",
"\"", "%22",
"\\", "%5C",
).Replace(s)
}
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string, tombstoneDays int) {
<!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"/>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
</head>
<body
x-data="bookDetail"
class={ "theme-" + user.Theme }
data-format-group={ book.FormatGroup }
data-library-id={ uuidToString(book.LibraryID) }
data-rating={ fmt.Sprintf("%d", getBookRating(book.Rating)) }
data-conflict-id={ conflictID(book.ActiveConflict) }
data-conflict-winner={ conflictWinnerSource(book.ActiveConflict) }
>
@Header(user, "/media/{ uuidToString(book.ID) }")
<!-- Hero with blurred cover backdrop -->
<div class="relative overflow-hidden">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<div class="hero-backdrop" style={ templ.SafeCSS("background-image: url(" + cssURL(book.CoverImagePath.String) + ")") }></div>
}
<div class="relative z-10 xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pt-6 pb-8">
<a id="back-link" href="/dashboard" class="inline-flex items-center gap-1.5 text-sm font-medium hover:underline transition-colors mb-6" style="color: var(--text-secondary);">
@Icon("arrow-left", "h-4 w-4")
<span>Back</span>
</a>
<div class="flex flex-col md:flex-row gap-8">
<!-- Cover -->
<div class="flex-shrink-0 mx-auto md:mx-0">
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
<img
src={ book.CoverImagePath.String }
alt={ book.Title }
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
style="box-shadow: var(--shadow-pop);"
onerror="this.src='/static/placeholder-book.svg'"
/>
} else {
<img
src="/static/placeholder-book.svg"
alt={ book.Title }
class="w-48 md:w-56 h-72 md:h-80 object-cover rounded-2xl"
style="box-shadow: var(--shadow-pop);"
/>
}
</div>
<!-- Details -->
<div class="flex-1 min-w-0">
<h1 class="text-3xl md:text-4xl font-bold tracking-tight mb-2" style="color: var(--text-primary)">{ book.Title }</h1>
if book.Subtitle.Valid && book.Subtitle.String != "" {
<p class="text-lg mb-2" style="color: var(--text-secondary)">{ book.Subtitle.String }</p>
}
if len(book.Authors) > 0 {
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
by
for i, authorName := range book.Authors {
if i > 0 {
<span>, </span>
}
<a href={ "/bookshelf?author_filter=" + url.QueryEscape(authorName) } class="hover:underline" style="color: var(--text-secondary);">{ authorName }</a>
}
</h2>
} else if book.Author.Valid && book.Author.String != "" {
<h2 class="text-lg md:text-xl mb-5" style="color: var(--text-secondary)">
by { book.Author.String }
</h2>
}
if book.IsAudio() && len(book.Narrators) > 0 {
<h3 class="text-sm md:text-base mb-5" style="color: var(--text-secondary)">
Narrated by
for i, narratorName := range book.Narrators {
if i > 0 {
<span>, </span>
}
@FacetLink("narrator", narratorName, narratorName)
}
</h3>
}
<!-- Action buttons -->
<div class="flex flex-wrap gap-2.5 mb-6">
<button
@click={ "window.location.href = '/readers/" + uuidToString(book.ID) + "'" }
class="btn btn-primary px-5 py-2.5"
>
@Icon(book.ActionIcon(), "h-4 w-4")
<span>{ book.ActionLabel() }</span>
</button>
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 {
<button @click="toggleRead(false)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check-circle", "h-4 w-4")<span>{ book.MarkResetLabel() }</span></span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
} else {
<button @click="toggleRead(true)" :disabled="readSaving" class="btn btn-secondary px-5 py-2.5">
<span x-show="!readSaving" class="inline-flex items-center gap-2">@Icon("check", "h-4 w-4")<span>{ book.MarkCompleteLabel() }</span></span>
<svg x-show="readSaving" class="animate-spin inline-block h-5 w-5" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</button>
}
if book.ActiveConflict != nil || book.ReadingProgress != nil {
<button @click="showProgressSyncModal()" class="btn btn-secondary px-5 py-2.5">
@Icon("sync", "h-4 w-4")
<span>Sync Progress</span>
</button>
}
<button @click="showNotesModal()" class="btn btn-secondary px-5 py-2.5">
@Icon("trash", "h-4 w-4")
<span>Deleted</span>
if len(book.DeletedAnnotations) > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ len(book.DeletedAnnotations) }</span>
}
</button>
<button @click="toggleAnnotationsBanner()" class="btn btn-secondary px-5 py-2.5">
@Icon("bookmark", "h-4 w-4")
<span>Annotations</span>
if book.NotesCount + book.HighlightsCount > 0 {
<span class="badge ml-1" style="background-color: var(--accent-muted); color: var(--accent);">{ book.NotesCount + book.HighlightsCount }</span>
}
</button>
if user.Role == "admin" {
<button @click="showMetadataEditor()" class="btn btn-secondary px-5 py-2.5">
@Icon("edit", "h-4 w-4")
<span>Edit</span>
</button>
}
</div>
<!-- Rating -->
<div class="mb-5" @mouseleave="ratingHover = 0">
<span class="text-2xl">
<template x-for="i in 5" :key="i">
<span style="position: relative; display: inline-block;">
<span :style="starFill(i)">★</span>
<button
type="button"
@click="setRating(i*2-1)"
@mouseenter="ratingHover = i*2-1"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2-1)/2) + ' of 5 stars'"
style="position: absolute; left: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
<button
type="button"
@click="setRating(i*2)"
@mouseenter="ratingHover = i*2"
:disabled="ratingSaving"
:aria-label="'Rate ' + ((i*2)/2) + ' of 5 stars'"
style="position: absolute; right: 0; top: 0; width: 50%; height: 100%; background: transparent; border: 0; padding: 0; margin: 0; cursor: pointer;"
></button>
</span>
</template>
</span>
<span class="ml-2 text-sm align-middle" style="color: var(--text-secondary);" x-text="ratingText()"></span>
<button
type="button"
x-show="userRating > 0 && !ratingSaving"
@click="clearRating()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
>Clear</button>
<!-- Review editor: markdown, same doctrine as notes -->
<button
type="button"
@click="openReviewEditor()"
class="ml-2 text-xs underline hover:opacity-70"
style="color: var(--text-secondary);"
x-text="hasReview ? 'Edit review' : 'Write review'"
></button>
<svg x-show="ratingSaving" class="animate-spin inline-block h-4 w-4 ml-1 align-middle" viewBox="0 0 24 24" fill="none" style="color: var(--text-secondary);">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
<!-- Community rating -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-3 text-sm" style="color: var(--text-secondary);">
Community:
<span class="font-bold ml-1" style="color: var(--text-primary);">
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
</span>
<span class="ml-2">{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }</span>
</div>
}
<!-- Badges: series, direction, tags -->
<div class="flex flex-wrap items-center gap-2 mb-3">
if book.Explicit {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);" title="Marked explicit in source metadata">Explicit</span>
}
if book.Series.Valid && book.Series.String != "" {
<a
href={ "/series/detail?name=" + url.QueryEscape(book.Series.String) + "&library_id=" + uuidToString(book.LibraryID) }
class="chip hover:opacity-90"
style="background-color: var(--accent); color: var(--bg-primary); text-decoration: none;"
>
{ book.Series.String }
if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 {
#{ fmtNumber(book.SeriesNumber.Float64) }
}
</a>
}
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<span class="chip" style="background-color: var(--accent); color: var(--bg-primary);">{ strings.ToUpper(book.ReadingDirection.String) }</span>
}
if book.AgeRating.Valid && book.AgeRating.String != "" {
@FacetBadge("age_rating", book.AgeRating.String, book.AgeRating.String)
}
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span class="badge" style="border: 1px solid var(--border); color: var(--text-secondary);">B&W</span>
}
if book.StoryArc.Valid && book.StoryArc.String != "" {
@FacetBadge("story_arc", book.StoryArc.String, book.StoryArc.String)
}
</div>
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="badge 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>
}
</div>
</div>
</div>
</div>
<div class="xs:w-full md:mx-auto md:container px-4 sm:px-6 lg:px-8 pb-10">
<!-- My Review (raw markdown hydrated client-side through the
notes pipeline — same sanitization as every note display) -->
if book.Review != nil && book.Review.Review != "" {
<div id="review-raw" class="hidden">{ book.Review.Review }</div>
<div id="review-card" class="card p-6 mb-6">
<h3 class="font-semibold mb-2" style="color: var(--text-primary)">My Review</h3>
<div
id="review-body"
class="note-rich max-h-[20rem] overflow-y-auto pr-2 text-sm leading-relaxed"
style="color: var(--text-secondary)"
x-html="reviewHtml"
></div>
</div>
}
<!-- Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="card p-6 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 text-sm leading-relaxed" style="color: var(--text-secondary)">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
</div>
</div>
}
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div class="card p-6 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 text-sm leading-relaxed" style="color: var(--text-secondary)">
@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
</div>
</div>
}
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div class="card p-6 mb-6">
<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 -->
if book.ReadingProgress != nil {
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">{ book.ProgressHeader() }</h3>
if book.ActiveConflict != nil {
<div class="mb-4 p-3 rounded-xl flex items-start gap-2" style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border: 1px solid var(--status-warning);">
@Icon("alert", "h-5 w-5 shrink-0")
<p class="text-sm" style="color: var(--text-primary);">Progress conflict detected — click "Sync Progress" to review and resolve.</p>
</div>
}
<div class="mb-4">
<div class="w-full rounded-full h-2.5" style="background-color: var(--bg-primary);">
<div
class="h-2.5 rounded-full transition-all"
style={ fmt.Sprintf("width: %.1f%%; background-color: var(--accent);", book.ReadingProgress.Percentage.Float64*100) }
></div>
</div>
</div>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
if book.IsAudio() {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Position</p>
<p class="text-2xl font-bold tabular-nums" style="color: var(--accent);">{ book.AudioCurrentLabel() + " / " + book.AudioDurationLabel() }</p>
</div>
} else {
<div>
<p class="text-xs uppercase tracking-wide" 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 class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Page</p>
<p style="color: var(--text-primary)">{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }</p>
</div>
}
if book.ReadingProgress.LastReadAt.Valid {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">{ book.LastReadLabel() }</p>
<p class="text-sm" style="color: var(--text-primary)">{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }</p>
</div>
}
if book.ReadingProgress.LastSyncSource.Valid {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Source</p>
<p class="capitalize" style="color: var(--text-primary)">{ book.ReadingProgress.LastSyncSource.String }</p>
</div>
}
</div>
</div>
}
<!-- Reading stats (the viewer's own sessions on this book) -->
if book.BookStats != nil {
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">{ book.StatsHeader() }</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">{ book.TimeStatLabel() }</p>
if book.BookStats.TotalMinutes >= 60 {
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60) }</p>
} else {
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d min", book.BookStats.TotalMinutes) }</p>
}
</div>
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.SessionCount) }</p>
</div>
if book.IsAudio() {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Time listened</p>
<p class="text-xl font-bold tabular-nums" style="color: var(--accent)">{ book.TimeListenedLabel() }</p>
</div>
} else {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages Synced</p>
<p class="text-xl font-bold" style="color: var(--accent)">{ fmt.Sprintf("%d", book.BookStats.TotalPages) }</p>
</div>
}
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Finished</p>
if book.BookStats.Finished {
<p class="text-xl font-bold" style="color: var(--text-primary)">✓</p>
} else {
<p class="text-xl font-bold" style="color: var(--text-secondary)">—</p>
}
</div>
</div>
</div>
}
<!-- Metadata grid -->
<div class="card p-6 mb-6">
<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-x-6 gap-y-4 text-sm">
if book.Publisher.Valid && book.Publisher.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Publisher</p>
<p style="color: var(--text-primary)">@FacetLink("publisher", book.Publisher.String, book.Publisher.String)</p>
</div>
}
if book.DatePublished.Valid {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Published</p>
<p style="color: var(--text-primary)">{ book.DatePublished.Time.Format("01-02-2006") }</p>
</div>
}
if book.Isbn.Valid && book.Isbn.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">ISBN</p>
<p style="color: var(--text-primary)">{ book.Isbn.String }</p>
</div>
}
if book.Language.Valid && book.Language.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Language</p>
<p style="color: var(--text-primary)">@FacetLink("language", book.Language.String, languageDisplay(book.Language.String))</p>
</div>
}
if book.IsAudio() && len(book.Narrators) > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Narrator</p>
<p style="color: var(--text-primary)">
for i, narratorName := range book.Narrators {
if i > 0 {
<span>, </span>
}
@FacetLink("narrator", narratorName, narratorName)
}
</p>
</div>
}
if book.IsAudio() && book.AudioDuration() > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Duration</p>
<p class="tabular-nums" style="color: var(--text-primary)">{ book.AudioDurationLabel() }</p>
</div>
}
if book.IsAudio() && book.Abridged {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Abridged</p>
<p style="color: var(--text-primary)">Yes</p>
</div>
}
if book.Edition.Valid && book.Edition.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Edition</p>
<p style="color: var(--text-primary)">@FacetLink("edition", book.Edition.String, book.Edition.String)</p>
</div>
}
if book.PageCount.Valid && book.PageCount.Int32 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
<p style="color: var(--text-primary)">{ book.PageCount.Int32 }</p>
</div>
}
if book.Genre.Valid && book.Genre.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Genre</p>
<p style="color: var(--text-primary)">@FacetLink("genre", book.Genre.String, prettifyGenre(book.Genre.String))</p>
</div>
}
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Series Count</p>
<p style="color: var(--text-primary)">{ book.SeriesCount.Int32 } items</p>
</div>
}
if book.Volume.Valid && book.Volume.Int32 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Volume</p>
<p style="color: var(--text-primary)">Vol. { book.Volume.Int32 }</p>
</div>
}
if book.Imprint.Valid && book.Imprint.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Imprint</p>
<p style="color: var(--text-primary)">@FacetLink("imprint", book.Imprint.String, book.Imprint.String)</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Copyright</p>
<p style="color: var(--text-primary)">{ book.CopyrightYear.Int32 }</p>
</div>
}
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Manga Type</p>
<p class="capitalize" style="color: var(--text-primary)">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
</div>
}
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Scan Info</p>
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
</div>
}
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Alternate Series</p>
<p style="color: var(--text-primary)">{ altSeries }</p>
</div>
}
if len(book.Contributors) > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Contributors</p>
<p style="color: var(--text-primary)">{ strings.Join(book.Contributors, ", ") }</p>
</div>
}
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Format</p>
<p style="color: var(--text-primary)">{ book.MimeType.String }</p>
</div>
if book.FileSize.Valid && book.FileSize.Int64 > 0 {
<div>
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">File Size</p>
<p style="color: var(--text-primary)">{ formatFileSize(book.FileSize.Int64) }</p>
</div>
}
if user.Role == "admin" {
<div class="md:col-span-2 lg:col-span-3">
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Location</p>
<p class="font-mono text-xs break-all select-all" style="color: var(--text-secondary)">{ book.FileLocation }</p>
</div>
}
</div>
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-5 pt-4 border-t flex flex-wrap gap-2" style="border-color: color-mix(in srgb, var(--text-primary) 7%, transparent);">
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="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Goodreads</a>
} else {
<a href={ getExternalURL("goodreads", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">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="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Open Library</a>
} else {
<a href={ getExternalURL("openlibrary", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">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="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Google Books</a>
} else {
<a href={ getExternalURL("googlebooks", "", book.Isbn, book.Title, book.Author) } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">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="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">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="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">Amazon</a>
}
if book.WebUrl.Valid && book.WebUrl.String != "" {
<a href={ book.WebUrl.String } target="_blank" rel="noopener noreferrer" class="chip hover:opacity-90" style="background-color: var(--accent-muted); color: var(--accent); text-decoration: none;">{ getDomainName(book.WebUrl.String) }</a>
}
</div>
}
</div>
<!-- Collections -->
if len(book.Collections) > 0 {
<div class="card p-6 mb-6">
<h3 class="font-semibold mb-4" style="color: var(--text-primary)">Collections</h3>
<div class="flex flex-wrap gap-2.5">
for _, col := range book.Collections {
<a
href={ "/collections/" + uuidToString(col.ID) }
class="inline-flex items-center gap-2 px-3 py-2 rounded-xl border hover:opacity-90 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>
@ProgressSyncModal(user, book)
@NotesHighlightsModal(user, book, tombstoneDays)
@AnnotationsBanner(book)
if user.Role == "admin" {
@MetadataEditorModal(book)
}
<!-- Review editor: plain textarea over the RAW markdown (what
storage and sync carry); rendering happens only on display. -->
<div
x-show="reviewOpen"
x-cloak
class="fixed inset-0 flex items-center justify-center z-50 p-4"
style="background-color: var(--surface-overlay);"
@keydown.escape.window="reviewOpen = false"
>
<div class="absolute inset-0" @click="reviewOpen = false"></div>
<div class="relative card p-6 w-full max-w-2xl">
<h3 class="text-lg font-semibold mb-3" style="color: var(--text-primary)">Edit Review</h3>
<textarea
x-model="reviewDraft"
rows="12"
class="w-full rounded-xl border p-3 text-sm font-mono resize-y"
style="background-color: var(--bg-primary); border-color: var(--border); color: var(--text-primary);"
placeholder="Markdown supported — **bold**, lists, footnotes…"
></textarea>
<p class="mt-1 text-xs" style="color: var(--text-secondary)">Markdown — renders like notes. Saving an empty review deletes it.</p>
<div class="mt-4 flex items-center gap-2">
<button type="button" @click="saveReview()" :disabled="reviewSaving" class="btn btn-primary">
<span x-show="!reviewSaving">Save review</span>
<span x-show="reviewSaving">Saving…</span>
</button>
<button type="button" @click="deleteReview()" :disabled="reviewSaving || !hasReview" class="btn btn-secondary" style="color: var(--status-danger, #e5484d);">Delete</button>
<button type="button" @click="reviewOpen = false" class="btn btn-secondary ml-auto">Cancel</button>
</div>
</div>
</div>
@ErrorToast(errorMessage)
</body>
</html>
}