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.
255 lines
6.1 KiB
Go
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")
|
|
}
|