Frontend half of the faceted-navigation Phase 2. 'Everything filterable should be clickable': every displayed facet value links to the bookshelf pre-filtered by its raw stored value; display forms are prettified at render, never in the DB. - templates/facet_links.templ: FacetLink (inline links) + FacetBadge (badge links) to /bookshelf?<facet>_filter=<value>; the raw value rides the param while the shown text is prettified, so matching and display can never disagree. Series/tags keep their dedicated pages by design; the card media-type icon stays unlinked (a derived kind, not a column value — deep-linking it would send wrong values). - book_detail.templ: publisher, language, edition, genre, imprint cells + narrators line + age-rating/story-arc badges are now links. - prettifyGenre (templates/utils.go + unit tests): title-cases only entirely-lowercase values; small words stay down except first position; & preserved. TS port for chip labels documents the twin. - bookshelf.templ + bookshelf.ts: active-filter chips bar — Alpine renders chips from client-local form state (sanctioned island); one bubbled htmx:afterRequest hook keeps chips + shareable URL in sync; removeFilterChip clears the facet and re-applies. URL deep links now accept all 18 facet params (hidden inputs where the drawer has no field). Pins changelog: Batch B/C entry in docs/contract_pins.md. Rendering ownership guard (make lint-rendering) green.
524 lines
14 KiB
Go
524 lines
14 KiB
Go
package templates
|
|
|
|
import (
|
|
"bookhoard/internal/database"
|
|
"bookhoard/internal/handlers"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
"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"
|
|
}
|
|
|
|
// themeCheckClass returns the class suffix for a theme option's checkmark
|
|
// wrapper: empty for the active theme (visible), " hidden" otherwise. The
|
|
// check element is always rendered so updateThemeIndicators() can move it
|
|
// client-side when the user picks a different theme.
|
|
func themeCheckClass(name, current string) string {
|
|
if name == current {
|
|
return ""
|
|
}
|
|
return " hidden"
|
|
}
|
|
|
|
// JSONArg marshals v as a JSON literal for embedding inside JavaScript
|
|
// attribute expressions (x-init, @click, …). Unlike templ.JSONString it is
|
|
// single-valued, so it composes in string concatenation. Strings come out
|
|
// quoted and escaped, so free text (titles, filenames) is safe to splice
|
|
// into Alpine expressions.
|
|
func JSONArg(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return `""`
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
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.
|
|
// MediaBadgeIcon selects the cover-corner badge glyph from the RESOLVED
|
|
// badge kind (handlers.MediaBadgeKind — format_group + library type;
|
|
// comic vs manga rides the typed-library doctrine). Books stay unbadged:
|
|
// the shelf's default reads as a book without a stamp.
|
|
func MediaBadgeIcon(badge string) string {
|
|
switch badge {
|
|
case "audiobook":
|
|
return "headphones"
|
|
case "comic":
|
|
return "comic"
|
|
case "manga":
|
|
return "manga"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func TotalMediaCount(libs []LibraryData) int64 {
|
|
var total int64
|
|
for _, l := range libs {
|
|
// Unmounted storage contributes nothing visible: an offline
|
|
// library's holdings stay in the database but out of the UI
|
|
// until its folders return.
|
|
if l.Offline {
|
|
continue
|
|
}
|
|
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
|
|
}
|
|
|
|
// genreSmallWords stay lowercase in title-case rendering (except the
|
|
// first word of the value).
|
|
var genreSmallWords = map[string]bool{
|
|
"a": true, "an": true, "and": true, "of": true, "the": true,
|
|
"in": true, "on": true, "for": true, "to": true, "or": true,
|
|
}
|
|
|
|
// prettifyGenre renders the canonically-lowercase genre value in title
|
|
// case at display time (Batch B transform — never in the DB). Only
|
|
// values that are ENTIRELY lowercase get transformed; anything else is
|
|
// returned verbatim, protecting user-entered mixed case. Small words
|
|
// stay down except in the first position; "&" has no case and passes
|
|
// through. "science fiction" → "Science Fiction", "sword & sorcery" →
|
|
// "Sword & Sorcery".
|
|
func prettifyGenre(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if trimmed != strings.ToLower(trimmed) {
|
|
return trimmed // mixed case = user-authored; display as written
|
|
}
|
|
words := strings.Fields(trimmed)
|
|
out := make([]string, len(words))
|
|
for i, w := range words {
|
|
if i > 0 && genreSmallWords[w] {
|
|
out[i] = w
|
|
continue
|
|
}
|
|
runes := []rune(w)
|
|
out[i] = string(append([]rune{unicode.ToUpper(runes[0])}, runes[1:]...))
|
|
}
|
|
return strings.Join(out, " ")
|
|
}
|
|
|
|
// 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")
|
|
}
|
|
|
|
// GroupTunableSettings splits a flat, group-sorted entry list into labeled
|
|
// sub-section groups, separated into "live" (applies immediately) and
|
|
// "restart required" buckets. Entries keep their original order so groups stay
|
|
// coherent.
|
|
func GroupTunableSettings(entries []SettingEntry) (live, restart []SettingGroup) {
|
|
var liveGroups, restartGroups []SettingGroup
|
|
for _, e := range entries {
|
|
target := &liveGroups
|
|
if e.RequiresRestart {
|
|
target = &restartGroups
|
|
}
|
|
if len(*target) == 0 || (*target)[len(*target)-1].Name != e.Group {
|
|
*target = append(*target, SettingGroup{Name: e.Group})
|
|
}
|
|
(*target)[len(*target)-1].Entries = append((*target)[len(*target)-1].Entries, e)
|
|
}
|
|
return liveGroups, restartGroups
|
|
}
|
|
|
|
// hxValsFolderDelete builds the hx-vals attribute for removing a library
|
|
// folder: htmx 2 requires valid JSON (or a js: expression) — string
|
|
// concatenation broke on paths containing quotes or backslashes.
|
|
func hxValsFolderDelete(folderPath string) string {
|
|
b, err := json.Marshal(map[string]string{"folder_path": folderPath})
|
|
if err != nil {
|
|
return `js:{"folder_path": ""}`
|
|
}
|
|
return "js:" + string(b)
|
|
}
|
|
|
|
// fmtSecondsHMS renders seconds as H:MM:SS (or M:SS under an hour) —
|
|
// the audio progress display shape shared with the web player.
|
|
func fmtSecondsHMS(seconds float64) string {
|
|
s := int(seconds + 0.5)
|
|
if s <= 0 {
|
|
return "0:00"
|
|
}
|
|
h := s / 3600
|
|
m := (s % 3600) / 60
|
|
sec := s % 60
|
|
if h > 0 {
|
|
return fmt.Sprintf("%d:%02d:%02d", h, m, sec)
|
|
}
|
|
return fmt.Sprintf("%d:%02d", m, sec)
|
|
}
|
|
|
|
// badgeLabel is the hover text for a cover badge.
|
|
func badgeLabel(badge string) string {
|
|
switch badge {
|
|
case "audiobook":
|
|
return "Audiobook"
|
|
case "comic":
|
|
return "Comic"
|
|
case "manga":
|
|
return "Manga"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Library type dropdown helpers. The option list comes from the DB-backed
|
|
// library_types table (the same source the create API resolves types
|
|
// against), so a new type appears in the UI without template edits.
|
|
|
|
// libraryTypeDisplayOrder pins the long-standing library types to their
|
|
// historical dropdown order.
|
|
var libraryTypeDisplayOrder = map[string]int{
|
|
"ebooks": 0,
|
|
"comics": 1,
|
|
"manga": 2,
|
|
"audiobooks": 3,
|
|
}
|
|
|
|
const libraryTypeRankUnknown = 1 << 30
|
|
|
|
func libraryTypeRank(name string) int {
|
|
if rank, ok := libraryTypeDisplayOrder[name]; ok {
|
|
return rank
|
|
}
|
|
return libraryTypeRankUnknown
|
|
}
|
|
|
|
// OrderedLibraryTypes sorts library types for a create-library dropdown:
|
|
// the original types keep their UI order, any newer types follow by name.
|
|
func OrderedLibraryTypes(types []database.LibraryTypes) []database.LibraryTypes {
|
|
sorted := make([]database.LibraryTypes, len(types))
|
|
copy(sorted, types)
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
ri, rj := libraryTypeRank(sorted[i].Name), libraryTypeRank(sorted[j].Name)
|
|
if ri != rj {
|
|
return ri < rj
|
|
}
|
|
return sorted[i].Name < sorted[j].Name
|
|
})
|
|
return sorted
|
|
}
|
|
|
|
// LibraryTypeDisplayName renders a library_types.name for the UI
|
|
// ("audiobooks" → "Audiobooks"); unknown names just get their first letter
|
|
// capitalized so a future type still reads sensibly.
|
|
func LibraryTypeDisplayName(name string) string {
|
|
switch name {
|
|
case "ebooks":
|
|
return "Ebooks"
|
|
case "comics":
|
|
return "Comics"
|
|
case "manga":
|
|
return "Manga"
|
|
case "audiobooks":
|
|
return "Audiobooks"
|
|
}
|
|
if name == "" {
|
|
return name
|
|
}
|
|
r := []rune(name)
|
|
return strings.ToUpper(string(r[0])) + string(r[1:])
|
|
}
|
|
|
|
// fmtNumber renders a series ordinal without a trailing decimal zero:
|
|
// 1 → "1", 1.5 → "1.5". Series numbers became double precision when
|
|
// mid-series novellas (0.5/1.5) arrived; "%d" no longer fits.
|
|
func fmtNumber(f float64) string {
|
|
return strconv.FormatFloat(f, 'f', -1, 64)
|
|
}
|
|
|
|
// authorsFormValue prefills an editable comma-separated author/publisher
|
|
// field: the array is the write truth; the legacy single value is the
|
|
// fallback for rows predating the arrays.
|
|
func authorsFormValue(arr []string, legacy string) string {
|
|
if len(arr) > 0 {
|
|
return strings.Join(arr, ", ")
|
|
}
|
|
return legacy
|
|
}
|