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.
624 lines
31 KiB
Templ
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>
|
|
}
|