Files
bookhoard/templates/utils.go
T
john-okeefe 05370d236a feat(ui): display media counts in library switcher
The UI had no surface showing how many media items have been imported.
Surface the total in the library switcher shown on the Dashboard, Series,
and Collections pages (via the LibrarySwitcher component) and in the
Bookshelf's inline library filter.

- Add a MediaCount field to LibraryData and a TotalMediaCount helper to
  sum counts for the "All Libraries" / "All Books" option.
- resolveLibrary() now fetches per-library counts (one query) and maps
  them onto each LibraryData entry, so the switcher reflects the active
  scope without changing the component's signature.
- Each library option renders "(N)" and the "All" option renders the
  grand total across the user's visible libraries.

The "All" total is the sum of the user's visible libraries, correctly
respecting per-user library visibility rather than a raw global count.

Regenerated templ files for library_switcher and bookshelf.
2026-07-30 11:41:13 -04:00

255 lines
6.1 KiB
Go

package templates
import (
"bookhoard/internal/database"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
"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
}
// TotalMediaCount sums the MediaCount across the given libraries,
// used to display the total next to the "All Libraries" option.
func TotalMediaCount(libs []LibraryData) int64 {
var total int64
for _, l := range libs {
total += l.MediaCount
}
return total
}
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, " ")
}
// FormatInTimezone formats a time.Time in the specified timezone as MM-DD-YYYY HH:MM
func FormatInTimezone(t time.Time, timezone string) string {
if t.IsZero() {
return ""
}
loc, err := time.LoadLocation(timezone)
if err != nil {
loc = time.UTC
}
return t.In(loc).Format("01-02-2006 03:04 PM")
}
// FormatTimestamptzInTimezone formats a pgtype.Timestamptz in the specified timezone
func FormatTimestamptzInTimezone(t pgtype.Timestamptz, timezone string) string {
if !t.Valid {
return ""
}
return FormatInTimezone(t.Time, timezone)
}
func textToString(t pgtype.Text) string {
if t.Valid {
return t.String
}
return ""
}
func tagSliceToString(tags []string) string {
return strings.Join(tags, ", ")
}
func stringSliceToString(s []string) string {
return strings.Join(s, ", ")
}
func formatDateForInput(d pgtype.Date) string {
if !d.Valid {
return ""
}
return d.Time.Format("2006-01-02")
}