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.
642 lines
32 KiB
Templ
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>
|
|
}
|