Files
bookhoard/templates/utils.go
T
john-okeefe 6f271d624f feat(ui): design system foundation
Add semantic design tokens and base primitives to replace the verbose
inline var() styling that made the UI feel dated.

- Rename colliding Tailwind color tokens (bg-primary/bg-secondary) to
  semantic names (surface/surface-raised/content/content-muted/brand/line)
- Derive hover, overlay, border-strong, accent-muted and elevation tokens
  once on <body> so they adapt to every theme automatically
- Make :root mirror Tokyo Night to kill the first-paint theme flash
- Add component primitives in @layer components: .card (surface + soft
  shadow, no hard border), .btn variants, .input, .chip, .badge, .icon-btn
- Add theme-aware status/priority badges and global focus-visible styling
- Add an inline-SVG Icon() component (consistent stroke language) to
  replace the mixed emoji/SVG iconography
- Data-drive theme/wood options and add an active-nav helper
- Add skeleton shimmer + x-cloak support
2026-08-05 16:00:50 -04:00

297 lines
7.6 KiB
Go

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 current == 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
}
// 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 class="text-brand">★</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 class="text-content-muted/40">★</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
}
// 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")
}