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
200 lines
4.9 KiB
Go
200 lines
4.9 KiB
Go
package templates
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
)
|
|
|
|
func activeClass(current, target string) string {
|
|
base := "block px-4 py-2 rounded-lg "
|
|
if current == target {
|
|
return base + "bg-accent text-bg-primary"
|
|
}
|
|
return base + "hover:opacity-80"
|
|
}
|
|
|
|
func ContainsString(slice []string, item string) bool {
|
|
for _, s := range slice {
|
|
if s == item {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func uuidToString(id pgtype.UUID) string {
|
|
if !id.Valid {
|
|
return ""
|
|
}
|
|
u, err := uuid.FromBytes(id.Bytes[0:16])
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return u.String()
|
|
}
|
|
|
|
// renderStars converts rating (1-10 scale) to star display
|
|
// Always displays 5 stars total with filled stars in yellow
|
|
// and empty stars in grey (using theme variable)
|
|
// Rating scale: 1-10 where each 2 points = 1 full star, odd numbers = half stars
|
|
func renderStars(rating int32) string {
|
|
const totalStars = 5
|
|
fullStars := rating / 2
|
|
hasHalf := rating%2 != 0
|
|
|
|
var stars strings.Builder
|
|
|
|
for i := int32(0); i < totalStars; i++ {
|
|
if i < fullStars {
|
|
// Filled star - accent color (theme-aware highlight)
|
|
stars.WriteString(`<span style="color: var(--accent);">★</span>`)
|
|
} else if i == fullStars && hasHalf {
|
|
// Half star - gradient from accent (left) to grey (right)
|
|
stars.WriteString(`<span style="background: linear-gradient(90deg, var(--accent) 50%, var(--text-secondary) 50%); -webkit-background-clip: text; -webkit-text-fill-color: transparent;">★</span>`)
|
|
} else {
|
|
// Empty star - grey using theme variable
|
|
stars.WriteString(`<span style="color: var(--text-secondary);">★</span>`)
|
|
}
|
|
}
|
|
|
|
return stars.String()
|
|
}
|
|
|
|
// formatFileSize converts bytes to human-readable format
|
|
func formatFileSize(bytes int64) string {
|
|
const (
|
|
KB = 1024
|
|
MB = KB * 1024
|
|
GB = MB * 1024
|
|
)
|
|
|
|
switch {
|
|
case bytes >= GB:
|
|
return fmt.Sprintf("%.2f GB", float64(bytes)/float64(GB))
|
|
case bytes >= MB:
|
|
return fmt.Sprintf("%.2f MB", float64(bytes)/float64(MB))
|
|
case bytes >= KB:
|
|
return fmt.Sprintf("%.2f KB", float64(bytes)/float64(KB))
|
|
default:
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
}
|
|
|
|
// getExternalURL generates URL for external book services
|
|
// Priority: ID > ISBN > Title+Author search
|
|
func getExternalURL(service string, id string, isbn pgtype.Text, title string, author pgtype.Text) string {
|
|
baseURL := ""
|
|
searchTerm := ""
|
|
|
|
// Determine search term: ID > ISBN > Title+Author
|
|
if id != "" {
|
|
searchTerm = id
|
|
} else if isbn.Valid && isbn.String != "" {
|
|
searchTerm = isbn.String
|
|
} else {
|
|
// Build title+author search query
|
|
if author.Valid && author.String != "" {
|
|
searchTerm = fmt.Sprintf("%s %s", title, author.String)
|
|
} else {
|
|
searchTerm = title
|
|
}
|
|
}
|
|
|
|
// Build URL based on service
|
|
switch service {
|
|
case "goodreads":
|
|
if id != "" {
|
|
baseURL = "https://www.goodreads.com/book/show/"
|
|
} else {
|
|
baseURL = "https://www.goodreads.com/search?q="
|
|
}
|
|
case "openlibrary":
|
|
if id != "" {
|
|
baseURL = "https://openlibrary.org/books/"
|
|
} else {
|
|
baseURL = "https://openlibrary.org/search?q="
|
|
}
|
|
case "googlebooks":
|
|
if id != "" {
|
|
baseURL = "https://books.google.com/books?id="
|
|
} else {
|
|
baseURL = "https://www.google.com/search?tbm=bks&q="
|
|
}
|
|
case "amazon":
|
|
// Amazon doesn't have direct book IDs, always search
|
|
baseURL = "https://www.amazon.com/s?k="
|
|
if isbn.Valid && isbn.String != "" {
|
|
searchTerm = isbn.String
|
|
}
|
|
}
|
|
|
|
return baseURL + searchTerm
|
|
}
|
|
|
|
// getBookRating returns the rating value or 0 if not rated
|
|
func getBookRating(rating *database.MediaRatings) int32 {
|
|
if rating != nil {
|
|
return rating.Rating
|
|
}
|
|
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, " ")
|
|
}
|