Files
bookhoard/templates/book_detail.templ
T
John O'Keefe fb1645c90c fix(metadata): m4b text-track fallback; in-app confirm; save-first write
Three defects from real-world use:

- The m4b rewrite assumed every stream survives -map 0 -c copy; files
  carrying text-ish tracks (the movenc 'text' fourcc) fail the ipod
  muxer with "Tag text incompatible with output codec id" — exactly the
  error a write attempt hit in the field. The rewrite now attempts full
  fidelity first, then retries mapping audio + attached pictures only
  (-dn -sn): chapters live in the container header (-map_chapters 0)
  so they survive; every attempt still ffprobe-validates before the
  atomic rename and the original is never touched on total failure.

- The browser confirm() is replaced by an in-app dialog
  (WriteToFileConfirmDialog, Alpine + templ — no native dialogs).

- "Write to file" now SAVES the pending editor edits first (persisted
  through the same PUT the Save button uses), then writes the file
  from those DB values, and closes the editor + refreshes the page
  exactly like Save — previously the endpoint wrote the LAST-SAVED DB
  values, so edits sitting in the modal never reached the file, and
  the page didn't visibly update.

Test coverage per the owner's bar: EVERY single-file container (MP3,
FLAC, M4A, M4B) gets its own runtime-generated fixture, gets written,
and the FILE's metadata is then verified independently — ffprobe reads
the rewritten container and every mapped key is asserted per format,
including the subtitle round-trip split (MP3/FLAC yes, M4A/B no) — and
the EPUB/CBZ/sidecar assertions now PARSE the written XML (dc titles/
creators, ComicInfo fields, calibre meta) instead of substring
matching. A smart-genre unit test also pins the mixed-blob contract: a
recognized shelf wins the genre column even when demoted format words
ride along in the same blob.
2026-10-27 19:05:23 -04:00

642 lines
32 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) }")
if book.ActiveConflict != nil {
<!-- Sync-conflict banner (user request): the progress
card's warning sits far down a long page — this
strip is unmissable at the very top, and clicking
it opens the same resolution modal. -->
<button
type="button"
@click="showProgressSyncModal()"
class="w-full flex items-center gap-3 px-4 sm:px-6 lg:px-8 py-3 text-left"
style="background-color: color-mix(in srgb, var(--status-warning) 14%, transparent); border-bottom: 1px solid var(--status-warning);"
>
@Icon("alert", "h-5 w-5 shrink-0")
<span class="text-sm" style="color: var(--text-primary);">
Progress sync conflict detected on this item — click to review and resolve.
</span>
</button>
}
<!-- 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);"
/>
} 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">
<a
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>
</a>
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)
@WriteToFileConfirmDialog(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>
}