feat: implement comic/manga metadata display on book detail page

Implement comprehensive comic metadata display features on the SSR book detail
page, supporting all 8 metadata fields from ComicInfo.xml and other sources.

## Template Changes (book_detail.templ)

### Step 1: Reading Direction Badge
- Display directional badge (RTL, LTR, VERTICAL) for manga/comics
- Uses 📖 icon with uppercase direction text
- Auto-hides when direction is "auto" (default)
- Styled with accent color for visibility

### Step 2: Community Rating Display
- Show pre-existing community rating from metadata (0.0-10.0 scale)
- Distinct from user ratings with visual differentiation
- Uses renderStars() helper for visual star display
- Shows both stars and numeric score (e.g., "★★★★☆ 8.5 / 10")
- Smaller, subtler styling than user rating

### Step 3: Comic-Specific Badges
- Age Rating: Content maturity indicator
- Black & White: Visual style badge
- Story Arc: Narrative arc name with 📚 icon
- Badges styled as pills with subtle borders
- Only display when values are present

### Step 4: Universal Series Info
- Series Count: Total items in series
- Volume: Volume/omnibus number
- Imprint: Publisher imprint (e.g., Vertigo)

### Step 5: Comic-Specific Metadata
- Manga Type: Raw/Comic/Manga classification
- Scan Information: Scanner group, resolution
- Alternate Series: Different series numbering

### Step 6: Summary Section
- Display ComicInfo.xml summary when present
- Separate from description field
- Sanitized HTML output with bluemonday
- Scrollable container for long summaries

### Step 7: Metadata Notes
- Technical notes from metadata files
- Internal/useful information (scanner, source, etc.)
- Card-style display with clear typography

### Step 8: Web URL Link
- External link to info sources (Goodreads, ComicVine, etc.)
- Opens in new tab with security attributes
- Displays clean domain name

## Utils Changes (templates/utils.go)

Added helper functions:
- getAlternateSeries(): Extract alternate series from JSONB
- getDomainName(): Extract clean domain for display
- formatAlternateInfo(): Format readable alternate series string

## Implementation Plan

Updated FRONTEND_IMPLEMENTATION_PLAN_COMIC_METADATA.md with:
- Disabled markdownlint for MD013 (line length)
- Added spacing for readability

## Technical Details

- All fields use pgtype.Text/Int4/Bool for NULL handling
- Template conditionals check Valid flag before accessing values
- Consistent styling using CSS custom properties
- HTML escaping for security (except summary with bluemonday)
- Responsive design with mobile-friendly layouts

Related: Database schema already supports all metadata fields
This commit is contained in:
2026-03-31 17:09:25 -04:00
parent 3a7c8e18c9
commit 562ca53d6e
4 changed files with 845 additions and 312 deletions
+135 -1
View File
@@ -4,6 +4,7 @@ import (
"bookhoard/internal/handlers"
"fmt"
"github.com/microcosm-cc/bluemonday"
"strings"
)
templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
@@ -101,6 +102,20 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
({ fmt.Sprintf("%.1f", float64(getBookRating(book.Rating))/2.0) } / 5)
</span>
</div>
<!-- Community Rating Display (from metadata) -->
if book.CommunityRating.Valid && book.CommunityRating.Float64 > 0 {
<div class="mb-2">
<span class="text-lg" style="color: var(--text-secondary);">
Community Rating:
<span class="font-bold" style="color: var(--text-primary);">
@templ.Raw(renderStars(int32(book.CommunityRating.Float64)))
</span>
<span class="ml-2 text-sm" style="color: var(--text-secondary);">
{ fmt.Sprintf("%.1f / 10", book.CommunityRating.Float64) }
</span>
</span>
</div>
}
<!-- Series Badge -->
if book.Series.Valid && book.Series.String != "" {
<div class="mb-4">
@@ -115,6 +130,47 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
</span>
</div>
}
<!-- Reading Direction Badge (for manga/comics) -->
if book.ReadingDirection.Valid && book.ReadingDirection.String != "" && book.ReadingDirection.String != "auto" {
<div class="mb-4">
<span
class="px-3 py-1 rounded-full text-sm font-semibold"
style="background-color: var(--accent); color: white;"
>
📖 { strings.ToUpper(book.ReadingDirection.String) }
</span>
</div>
}
<!-- Comic-Specific Badges -->
<div class="flex flex-wrap gap-2 mb-4">
<!-- Age Rating Badge -->
if book.AgeRating.Valid && book.AgeRating.String != "" {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
{ book.AgeRating.String }
</span>
}
<!-- Black and White Badge -->
if book.IsBlackAndWhite.Valid && book.IsBlackAndWhite.Bool {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
B&W
</span>
}
<!-- Story Arc Badge -->
if book.StoryArc.Valid && book.StoryArc.String != "" {
<span
class="px-2 py-1 rounded-full text-xs font-semibold"
style="background-color: var(--bg-primary); border: 1px solid var(--border); color: var(--text-secondary);"
>
📚 { book.StoryArc.String }
</span>
}
</div>
<!-- Description/Synopsis -->
if book.Description.Valid && book.Description.String != "" {
<div class="mb-6">
@@ -128,6 +184,29 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
}
</div>
</div>
<!-- Summary (from ComicInfo.xml, if different from description) -->
if book.Summary.Valid && book.Summary.String != "" && book.Summary.String != book.Description.String {
<div class="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" style="color: var(--text-secondary)">
@UnsafeHTML(
bluemonday.UGCPolicy().Sanitize(book.Summary.String),
).ToComponent()
</div>
</div>
}
<!-- Metadata Notes (technical notes from metadata files) -->
if book.MetadataNotes.Valid && book.MetadataNotes.String != "" {
<div
class="card p-6 rounded-lg border mb-6"
style="background-color: var(--bg-secondary); border-color: var(--border);"
>
<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 Section -->
if book.ReadingProgress != nil {
<div
@@ -233,12 +312,56 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
<p>{ book.Genre.String }</p>
</div>
}
<!-- Series Information (if not shown in badge) -->
if book.SeriesCount.Valid && book.SeriesCount.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Series Count</p>
<p>{ book.SeriesCount.Int32 } items</p>
</div>
}
if book.Volume.Valid && book.Volume.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Volume</p>
<p>Vol. { book.Volume.Int32 }</p>
</div>
}
if book.Imprint.Valid && book.Imprint.String != "" {
<div>
<p style="color: var(--text-secondary)">Imprint</p>
<p>{ book.Imprint.String }</p>
</div>
}
if book.CopyrightYear.Valid && book.CopyrightYear.Int32 > 0 {
<div>
<p style="color: var(--text-secondary)">Copyright Year</p>
<p>{ book.CopyrightYear.Int32 }</p>
</div>
}
<!-- Comic-Specific Fields -->
if book.MangaType.Valid && book.MangaType.String != "" && book.MangaType.String != "unknown" {
<div>
<p style="color: var(--text-secondary)">Manga Type</p>
<p class="capitalize">{ strings.ReplaceAll(book.MangaType.String, "_", " ") }</p>
</div>
}
if book.ScanInformation.Valid && book.ScanInformation.String != "" {
<div>
<p style="color: var(--text-secondary)">Scan Info</p>
<p class="text-sm" style="color: var(--text-secondary);">{ book.ScanInformation.String }</p>
</div>
}
if len(book.AlternateInfo) > 0 {
<div>
<p style="color: var(--text-secondary)">Alternate Series</p>
<p class="text-sm">{ string(book.AlternateInfo) }</p>
</div>
}
if altSeries := getAlternateSeries(book.AlternateInfo); altSeries != "" {
<div>
<p style="color: var(--text-secondary)">Alternate Series</p>
<p>{ altSeries }</p>
</div>
}
<!-- Technical Info -->
<div>
<p style="color: var(--text-secondary)">Format</p>
@@ -252,7 +375,7 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
}
</div>
<!-- External IDs Section -->
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid {
if book.GoodreadsID.Valid || book.OpenlibraryID.Valid || book.GoogleBooksID.Valid || book.Asin.Valid || book.Isbn.Valid || book.WebUrl.Valid {
<div class="mt-4 pt-4 border-t" style="border-color: var(--border);">
<h4 class="text-sm font-semibold mb-3" style="color: var(--text-primary)">External Links</h4>
<div class="flex flex-wrap gap-3">
@@ -340,6 +463,17 @@ templ BookDetail(user User, book handlers.MediaDetail, errorMessage string) {
🛒 Amazon
</a>
}
if book.WebUrl.Valid && book.WebUrl.String != "" {
<a
href={ book.WebUrl.String }
target="_blank"
rel="noopener noreferrer"
class="text-sm hover:underline flex items-center gap-1"
style="color: var(--accent);"
>
🔗 { getDomainName(book.WebUrl.String) }
</a>
}
</div>
</div>
}
File diff suppressed because it is too large Load Diff
+55
View File
@@ -2,7 +2,9 @@ package templates
import (
"bookhoard/internal/database"
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/google/uuid"
@@ -142,3 +144,56 @@ func getBookRating(rating *database.MediaRatings) int32 {
}
return 0
}
// getAlternateSeries extracts the alternate series name from JSONB data
func getAlternateSeries(data []byte) string {
if len(data) == 0 {
return ""
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return ""
}
if series, ok := result["alternate_series"].(string); ok {
return series
}
return ""
}
// getDomainName extracts a clean domain name from URL for display
func getDomainName(rawURL string) string {
if rawURL == "" {
return "Source"
}
u, err := url.Parse(rawURL)
if err != nil {
return "Source"
}
// Return hostname without www.
hostname := u.Hostname()
return strings.TrimPrefix(hostname, "www.")
}
// formatAlternateInfo formats alternate series info as readable string
func formatAlternateInfo(data []byte) string {
if len(data) == 0 {
return ""
}
var result map[string]interface{}
if err := json.Unmarshal(data, &result); err != nil {
return ""
}
var parts []string
if series, ok := result["alternate_series"].(string); ok && series != "" {
parts = append(parts, series)
}
if num, ok := result["alternate_number"].(float64); ok && num > 0 {
parts = append(parts, fmt.Sprintf("#%d", int(num)))
}
if count, ok := result["alternate_count"].(float64); ok && count > 0 {
parts = append(parts, fmt.Sprintf("(of %d)", int(count)))
}
return strings.Join(parts, " ")
}