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.Title } - Bookhoard @Header(user, "/media/{ uuidToString(book.ID) }")
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" {
}
@Icon("arrow-left", "h-4 w-4") Back
if book.CoverImagePath.Valid && book.CoverImagePath.String != "" { { } else { { }

{ book.Title }

if book.Subtitle.Valid && book.Subtitle.String != "" {

{ book.Subtitle.String }

} if len(book.Authors) > 0 {

by for i, authorName := range book.Authors { if i > 0 { , } { authorName } }

} else if book.Author.Valid && book.Author.String != "" {

by { book.Author.String }

} if book.IsAudio() && len(book.Narrators) > 0 {

Narrated by for i, narratorName := range book.Narrators { if i > 0 { , } @FacetLink("narrator", narratorName, narratorName) }

}
if book.ReadingProgress != nil && book.ReadingProgress.Percentage.Valid && book.ReadingProgress.Percentage.Float64 >= 1.0 { } else { } if book.ActiveConflict != nil || book.ReadingProgress != nil { } if user.Role == "admin" { }
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
Community: @templ.Raw(renderStars(int32(book.CommunityRating.Float64))) { fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
}
if book.Explicit { Explicit } if book.Series.Valid && book.Series.String != "" { { book.Series.String } if book.SeriesNumber.Valid && book.SeriesNumber.Float64 > 0 { #{ fmtNumber(book.SeriesNumber.Float64) } } } if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" { { strings.ToUpper(book.ReadingDirection.String) } } if book.AgeRating.Valid && book.AgeRating.String != "" { @FacetBadge("age_rating", book.AgeRating.String, book.AgeRating.String) } if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool { B&W } if book.StoryArc.Valid && book.StoryArc.String != "" { @FacetBadge("story_arc", book.StoryArc.String, book.StoryArc.String) }
if len(book.Tags) > 0 {
for _, tag := range book.Tags { { tag } }
}
if book.Review != nil && book.Review.Review != "" {

My Review

} if book.Description.Valid && book.Description.String != "" {

Synopsis

@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Description.String)).ToComponent()
} if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {

Comic Summary

@UnsafeHTML(bluemonday.UGCPolicy().Sanitize(book.Summary.String)).ToComponent()
} if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {

Metadata Notes

{ book.MetadataNotes.String }
} if book.ReadingProgress != nil {

{ book.ProgressHeader() }

if book.ActiveConflict != nil {
@Icon("alert", "h-5 w-5 shrink-0")

Progress conflict detected — click "Sync Progress" to review and resolve.

}
if book.IsAudio() {

Position

{ book.AudioCurrentLabel() + " / " + book.AudioDurationLabel() }

} else {

Progress

{ fmt.Sprintf("%.1f", book.ReadingProgress.Percentage.Float64*100) }%

} if book.ReadingProgress.CurrentPage.Valid && book.ReadingProgress.TotalPages.Valid {

Page

{ book.ReadingProgress.CurrentPage.Int32 } / { book.ReadingProgress.TotalPages.Int32 }

} if book.ReadingProgress.LastReadAt.Valid {

{ book.LastReadLabel() }

{ FormatTimestamptzInTimezone(book.ReadingProgress.LastReadAt, user.Timezone) }

} if book.ReadingProgress.LastSyncSource.Valid {

Source

{ book.ReadingProgress.LastSyncSource.String }

}
} if book.BookStats != nil {

{ book.StatsHeader() }

{ 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) }

if book.IsAudio() {

Time listened

{ book.TimeListenedLabel() }

} else {

Pages Synced

{ fmt.Sprintf("%d", book.BookStats.TotalPages) }

}

Finished

if book.BookStats.Finished {

✓

} else {

—

}
}

Metadata

if book.Publisher.Valid && book.Publisher.String != "" {

Publisher

@FacetLink("publisher", book.Publisher.String, book.Publisher.String)

} if book.DatePublished.Valid {

Published

{ book.DatePublished.Time.Format("01-02-2006") }

} if book.Isbn.Valid && book.Isbn.String != "" {

ISBN

{ book.Isbn.String }

} if book.Language.Valid && book.Language.String != "" {

Language

@FacetLink("language", book.Language.String, languageDisplay(book.Language.String))

} if book.IsAudio() && len(book.Narrators) > 0 {

Narrator

for i, narratorName := range book.Narrators { if i > 0 { , } @FacetLink("narrator", narratorName, narratorName) }

} if book.IsAudio() && book.AudioDuration() > 0 {

Duration

{ book.AudioDurationLabel() }

} if book.IsAudio() && book.Abridged {

Abridged

Yes

} if book.Edition.Valid && book.Edition.String != "" {

Edition

@FacetLink("edition", book.Edition.String, book.Edition.String)

} if book.PageCount.Valid && book.PageCount.Int32 > 0 {

Pages

{ book.PageCount.Int32 }

} if book.Genre.Valid && book.Genre.String != "" {

Genre

@FacetLink("genre", book.Genre.String, prettifyGenre(book.Genre.String))

} if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {

Series Count

{ book.SeriesCount.Int32 } items

} if book.Volume.Valid && book.Volume.Int32 > 0 {

Volume

Vol. { book.Volume.Int32 }

} if book.Imprint.Valid && book.Imprint.String != "" {

Imprint

@FacetLink("imprint", book.Imprint.String, book.Imprint.String)

} if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {

Copyright

{ book.CopyrightYear.Int32 }

} if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {

Manga Type

{ strings.ReplaceAll(book.MangaType.String, "_", " ") }

} if book.ScanInformation.Valid && book.ScanInformation.String != "" {

Scan Info

{ book.ScanInformation.String }

} if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {

Alternate Series

{ altSeries }

} if len(book.Contributors) > 0 {

Contributors

{ strings.Join(book.Contributors, ", ") }

}

Format

{ book.MimeType.String }

if book.FileSize.Valid && book.FileSize.Int64 > 0 {

File Size

{ formatFileSize(book.FileSize.Int64) }

} if user.Role == "admin" {

Location

{ book.FileLocation }

}
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
if book.GoodreadsID.Valid && book.GoodreadsID.String != "" { Goodreads } else { Goodreads } if book.OpenlibraryID.Valid && book.OpenlibraryID.String != "" { Open Library } else { Open Library } if book.GoogleBooksID.Valid && book.GoogleBooksID.String != "" { Google Books } else { Google Books } if book.Asin.Valid && book.Asin.String != "" { Amazon } else if book.Isbn.Valid && book.Isbn.String != "" { Amazon } if book.WebUrl.Valid && book.WebUrl.String != "" { { getDomainName(book.WebUrl.String) } }
}
if len(book.Collections) > 0 {

Collections

for _, col := range book.Collections { { col.Icon.String } { col.Name } }
}
@ProgressSyncModal(user, book) @NotesHighlightsModal(user, book, tombstoneDays) @AnnotationsBanner(book) if user.Role == "admin" { @MetadataEditorModal(book) }

Edit Review

Markdown — renders like notes. Saving an empty review deletes it.

@ErrorToast(errorMessage) }