Two visibility fixes so the UI reflects the shelf's real state on the next scan instead of only after the archive gate: - Missing items disappear at once: the user-facing filters already hid archived rows; the same listings now also require missing_scan_count = 0. A deleted or moved-then-not-yet-repointed file vanishes from the UI immediately, while purge timing stays gated on archived_at plus the retention window - grace protects data, not visibility. Restored automatically when the file returns. - Steam Deck SD-card model for unmounted storage: resolveLibrary stats each library's folder roots and flags libraries with no live folder as Offline (LibraryData gains the field, computed at request time so mounts/unmounts react instantly). The bookshelf shows an empty shelf plus a 'storage is not connected' notice for an offline selected library, and both the shared LibrarySwitcher and the bookshelf's inline select label offline libraries with their true holding counts. Nothing is marked or purged while offline. - TotalMediaCount skips offline libraries, so the 'All Books/Libraries' totals match what is actually visible. Verified live: renaming uploads/Manga away produced the notice, an empty shelf, and the offline dropdown label with a corrected total; renaming it back restored all 38 cards with zero dirty rows.
342 lines
9.1 KiB
Go
342 lines
9.1 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 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"
|
|
}
|
|
|
|
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 {
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|