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) {
{ book.Subtitle.String }
} if len(book.Authors) > 0 {Progress conflict detected — click "Sync Progress" to review and resolve.
Position
{ book.AudioCurrentLabel() + " / " + book.AudioDurationLabel() }
Progress
{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%
Page
{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }
{ book.LastReadLabel() }
{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }
Source
{ book.ReadingProgress.LastSyncSource.String }
{ book.TimeStatLabel() }
if book.BookStats.TotalMinutes >= 60 {{ fmt.Sprintf("%dh %dmin", book.BookStats.TotalMinutes/60, book.BookStats.TotalMinutes%60) }
} else {{ fmt.Sprintf("%d min", book.BookStats.TotalMinutes) }
}Sessions
{ fmt.Sprintf("%d", book.BookStats.SessionCount) }
Time listened
{ book.TimeListenedLabel() }
Pages Synced
{ fmt.Sprintf("%d", book.BookStats.TotalPages) }
Finished
if book.BookStats.Finished {✓
} else {—
}Publisher
@FacetLink("publisher", book.Publisher.String, book.Publisher.String)
Published
{ book.DatePublished.Time.Format("01-02-2006") }
ISBN
{ book.Isbn.String }
Language
@FacetLink("language", book.Language.String, languageDisplay(book.Language.String))
Narrator
for i, narratorName := range book.Narrators { if i > 0 { , } @FacetLink("narrator", narratorName, narratorName) }
Duration
{ book.AudioDurationLabel() }
Abridged
Yes
Edition
@FacetLink("edition", book.Edition.String, book.Edition.String)
Pages
{ book.PageCount.Int32 }
Genre
@FacetLink("genre", book.Genre.String, prettifyGenre(book.Genre.String))
Series Count
{ book.SeriesCount.Int32 } items
Volume
Vol. { book.Volume.Int32 }
Imprint
@FacetLink("imprint", book.Imprint.String, book.Imprint.String)
Copyright
{ book.CopyrightYear.Int32 }
Manga Type
{ strings.ReplaceAll(book.MangaType.String, "_", " ") }
Scan Info
{ book.ScanInformation.String }
Alternate Series
{ altSeries }
Contributors
{ strings.Join(book.Contributors, ", ") }
Format
{ book.MimeType.String }
File Size
{ formatFileSize(book.FileSize.Int64) }
Location
{ book.FileLocation }
Markdown — renders like notes. Saving an empty review deletes it.