package templates import ( "bookhoard/internal/database" "bookhoard/internal/handlers" "encoding/json" "fmt" "net/url" "sort" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgtype" ) func activeClass(current, target string) string { base := "flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors " if strings.TrimRight(current, "/") == strings.TrimRight(target, "/") { return base + "bg-brand/15 text-brand" } return base + "text-content-muted hover:bg-surface-hover hover:text-content" } // navItemClass returns classes for top-nav links, marking the active route. func navItemClass(current, target string) string { base := "text-sm font-medium px-3 py-2 rounded-lg transition-colors " if current == target { return base + "text-content bg-surface-hover" } return base + "text-content-muted hover:text-content hover:bg-surface-hover" } func ContainsString(slice []string, item string) bool { for _, s := range slice { if s == item { return true } } return false } func isUserVisible(userID string, visibility []UserVisibilityData) bool { for _, v := range visibility { if v.UserID == userID { return v.IsVisible } } 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(``) } else if i == fullStars && hasHalf { // Half star - gradient from accent (left) to grey (right) stars.WriteString(``) } else { // Empty star - grey using theme variable stars.WriteString(``) } } 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 } // conflictWinnerSource returns a valid source key from a conflict's // conflict_data map, to pass as the "winner" when resolving it. It prefers // "web" (since the user is acting via the web UI) and otherwise falls back to // the lexicographically smallest key. The chosen winner does not affect the // final read/unread state, which is set by a subsequent progress write; it only // needs to be a key present in the conflict data so the resolve endpoint // accepts it and arms its 10-minute suppression window. func conflictWinnerSource(c *handlers.ConflictDetailResponse) string { if c == nil || len(c.ConflictData) == 0 { return "" } if _, ok := c.ConflictData["web"]; ok { return "web" } keys := make([]string, 0, len(c.ConflictData)) for k := range c.ConflictData { keys = append(keys, k) } sort.Strings(keys) return keys[0] } // conflictID returns the active conflict's ID, or "" when there is none. Used // to render a data-conflict-id attribute the frontend can read. func conflictID(c *handlers.ConflictDetailResponse) string { if c == nil { return "" } return c.ID } // 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") }