Release / build-and-push (push) Successful in 3m31s
templ does not interpolate { expr } inside quoted attribute values — the
braces ship as literal text (AGENTS.md gotcha #1). Swept every templ
source for the pattern and fixed all of it:
Body theme classes: 22 page bodies rendered class="theme-{ user.Theme }"
literally, so every page first-painted the :root default (Tokyo Night)
and flashed when main.js applied the real theme. All now render
class={ "theme-" + user.Theme } server-side.
Attribute interpolations (26 across 8 files):
- api_explorer: initAPIExplorerDoc path/body/response and the Copy
Request/Response buttons (invalid JS, silently dead)
- collections: book-selection :checked/@change/@click ids and four
hx-get search URLs (collection_id={ collection.ID })
- conflicts: Resolve button id
- devices: Kobo sync-url/auth-token element ids (copy lookups only
worked because the id and the lookup were the same literal)
- unlinked_books: search-matches/manual-link clicks and container ids
- queue: status/priority badge classes never matched their CSS
- dashboard: Restore-section tooltip text
- collection_rules/collections: collection icon color
Free text goes through the new templates.JSONArg (json.Marshal) —
templ.JSONString in this templ version returns (string, error) and
cannot compose in attribute expressions; uuids via device.ID.String().
Includes regen-only drift from running templ generate (error-path
normalization) on files with no source change.
407 lines
16 KiB
Templ
407 lines
16 KiB
Templ
package templates
|
|
|
|
import (
|
|
"bookhoard/internal/handlers"
|
|
"fmt"
|
|
)
|
|
|
|
templ Dashboard(user User, sections []handlers.SectionData, allSections []handlers.SectionData, libData []LibraryData, currentLibraryID string, hiddenCollections []string, itemsPerSection int, week *WeekStatsData, activeLibType string, errorMessage string) {
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8"/>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
|
<title>Dashboard - Bookhoard</title>
|
|
<script src="/static/htmx.min.js"></script>
|
|
<link href="/static/style.css" rel="stylesheet"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="dashboard" x-init="initDashboard()" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/dashboard")
|
|
@LibrarySwitcher(libData, currentLibraryID, "Your Library", DashboardActions())
|
|
<!-- OUTSIDE #collections-container on purpose: the switcher's
|
|
re-render wipes that container's innerHTML, and anything
|
|
server-rendered inside it dies on the first library
|
|
switch. The widget re-fetches itself via htmx instead. -->
|
|
if week != nil {
|
|
<div class="w-full px-4 sm:px-6 lg:px-8 pt-6">
|
|
@DashboardWeekWidget(*week, activeLibType)
|
|
</div>
|
|
}
|
|
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 pt-5 pb-8">
|
|
for _, section := range sections {
|
|
@CollectionCarousel(section)
|
|
}
|
|
</main>
|
|
@DashboardSettingsModal(allSections, hiddenCollections, itemsPerSection)
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ CollectionCarousel(section handlers.SectionData) {
|
|
<div
|
|
class="dashboard-collection mb-10"
|
|
data-collection-id={ section.ID }
|
|
data-is-system={ section.IsSystem }
|
|
>
|
|
<div class="flex items-end justify-between mb-4">
|
|
<div class="flex items-center gap-3">
|
|
<span class="grid place-items-center h-9 w-9 rounded-lg text-lg" style="background-color: var(--accent-muted);">{ section.Icon }</span>
|
|
<div>
|
|
<h2 class="text-lg font-bold tracking-tight" style="color: var(--text-primary)">{ section.Title }</h2>
|
|
if section.Description != "" {
|
|
<p class="text-xs" style="color: var(--text-secondary)">{ section.Description }</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
if section.ViewAllURL != "" {
|
|
<a
|
|
href={ section.ViewAllURL }
|
|
class="inline-flex items-center gap-1 text-sm font-medium hover:underline transition-colors"
|
|
style="color: var(--accent);"
|
|
>
|
|
View All
|
|
@Icon("arrow-right", "h-4 w-4")
|
|
</a>
|
|
}
|
|
</div>
|
|
<div class="carousel-container relative group">
|
|
<button
|
|
class="carousel-nav-left absolute left-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
|
data-action="scroll-carousel"
|
|
data-collection-id={ section.ID }
|
|
data-direction="-1"
|
|
aria-label="Scroll left"
|
|
style="background: linear-gradient(to right, var(--bg-primary), transparent);"
|
|
>
|
|
@Icon("chevron-left", "h-7 w-7")
|
|
</button>
|
|
<div
|
|
id={ "carousel-track-" + section.ID }
|
|
class="carousel-track flex gap-4 overflow-x-auto scroll-smooth snap-x snap-mandatory px-6 pb-2"
|
|
style="scrollbar-width: none; -ms-overflow-style: none;"
|
|
>
|
|
for _, item := range section.Items {
|
|
<div class="flex-shrink-0 w-36 sm:w-40 snap-start" data-media-item-id={ item.MediaItemID }>
|
|
@BookCard(item)
|
|
</div>
|
|
}
|
|
if len(section.Items) == 0 {
|
|
<div class="flex flex-col items-center justify-center text-center py-12 w-full gap-2" style="color: var(--text-secondary);">
|
|
@Icon("book", "h-8 w-8 opacity-50")
|
|
<p class="text-sm">No items in this collection</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
<button
|
|
class="carousel-nav-right absolute right-0 top-1/2 -translate-y-1/2 z-10 h-full w-12 flex items-center justify-center opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 transition-opacity duration-200"
|
|
data-action="scroll-carousel"
|
|
data-collection-id={ section.ID }
|
|
data-direction="1"
|
|
aria-label="Scroll right"
|
|
style="background: linear-gradient(to left, var(--bg-primary), transparent);"
|
|
>
|
|
@Icon("chevron-right", "h-7 w-7")
|
|
</button>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ BookCard(item handlers.BookInfo) {
|
|
<div class="book-card relative w-full h-full rounded-xl overflow-hidden cursor-pointer">
|
|
<a href={ "/media/" + item.MediaItemID } class="block h-full">
|
|
<div
|
|
class="book-card-cover aspect-[2/3] overflow-hidden"
|
|
style="background-color: color-mix(in srgb, var(--text-primary) 8%, transparent);"
|
|
>
|
|
if item.CoverImagePath != "" {
|
|
<img
|
|
src={ item.CoverImagePath }
|
|
alt={ item.Title }
|
|
class="w-full h-full object-cover"
|
|
loading="lazy"
|
|
onerror="this.src='/static/placeholder-book.svg'"
|
|
/>
|
|
} else {
|
|
<img
|
|
src="/static/placeholder-book.svg"
|
|
alt={ item.Title }
|
|
class="w-full h-full object-cover"
|
|
/>
|
|
}
|
|
<!-- Media-type identifier (A2c): books stay unbadged — the
|
|
shelf default already reads as a book -->
|
|
if MediaBadgeIcon(item.Badge) != "" {
|
|
<span
|
|
class="absolute top-2 right-2 z-10 flex items-center justify-center h-7 w-7 rounded-full shadow-sm"
|
|
style="background-color: color-mix(in srgb, var(--bg-primary) 78%, transparent); color: var(--text-primary); backdrop-filter: blur(3px);"
|
|
title={ badgeLabel(item.Badge) }
|
|
>
|
|
@Icon(MediaBadgeIcon(item.Badge), "h-4 w-4")
|
|
</span>
|
|
}
|
|
</div>
|
|
<div class="book-card-meta">
|
|
<h3 class="font-semibold text-sm leading-snug line-clamp-2" style="color: var(--text-primary)" title={ item.Title }>
|
|
{ item.Title }
|
|
</h3>
|
|
if item.Author != "" {
|
|
<p class="text-xs mt-0.5 line-clamp-1" style="color: var(--text-secondary)" title={ item.Author }>
|
|
{ item.Author }
|
|
</p>
|
|
}
|
|
</div>
|
|
</a>
|
|
<div class="book-card-action">
|
|
if item.HasConflict {
|
|
<a
|
|
href={ "/media/" + item.MediaItemID }
|
|
class="book-card-action-btn"
|
|
aria-label={ "Resolve progress conflict for " + item.Title }
|
|
title="Resolve progress conflict"
|
|
>
|
|
@Icon("book-open", "h-5 w-5")
|
|
</a>
|
|
} else if item.FormatGroup == "audiobook" {
|
|
<a
|
|
href={ "/readers/" + item.MediaItemID }
|
|
class="book-card-action-btn"
|
|
aria-label={ "Listen to " + item.Title }
|
|
title="Listen"
|
|
>
|
|
@Icon("headphones", "h-5 w-5")
|
|
</a>
|
|
} else {
|
|
<a
|
|
href={ "/readers/" + item.MediaItemID }
|
|
class="book-card-action-btn"
|
|
aria-label={ "Read " + item.Title }
|
|
title="Read"
|
|
>
|
|
@Icon("book-open", "h-5 w-5")
|
|
</a>
|
|
}
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
templ DashboardSettingsModal(sections []handlers.SectionData, hiddenCollections []string, itemsPerSection int) {
|
|
<div
|
|
id="dashboard-settings-modal"
|
|
class="hidden fixed inset-0 z-[70] flex items-center justify-center p-4"
|
|
style="background-color: var(--surface-overlay);"
|
|
>
|
|
<div class="card p-6 w-full max-w-2xl shadow-2xl" style="box-shadow: var(--shadow-pop);">
|
|
<div class="flex justify-between items-center mb-6">
|
|
<div>
|
|
<h2 class="text-xl font-bold" style="color: var(--text-primary)">Customize Dashboard</h2>
|
|
<p class="text-sm mt-1" style="color: var(--text-secondary)">Drag to reorder, toggle to show or hide.</p>
|
|
<p class="text-xs mt-1 font-medium" id="dashboard-scope-label" style="color: var(--accent)"></p>
|
|
</div>
|
|
<button data-action="close-dashboard-settings" class="icon-btn" aria-label="Close">
|
|
@Icon("close", "h-5 w-5")
|
|
</button>
|
|
</div>
|
|
<div id="collection-list" class="space-y-2 mb-6">
|
|
for _, section := range sections {
|
|
<div
|
|
class="collection-item flex items-center justify-between p-3 rounded-xl cursor-move select-none card"
|
|
data-collection-id={ section.ID }
|
|
data-is-system={ fmt.Sprintf("%v", section.IsSystem) }
|
|
draggable="true"
|
|
>
|
|
<div class="flex items-center gap-3">
|
|
@Icon("grip", "h-5 w-5 shrink-0")
|
|
<span class="text-xl">{ section.Icon }</span>
|
|
<div class="flex items-center gap-2">
|
|
<span class="font-medium" style="color: var(--text-primary)">{ section.Title }</span>
|
|
if section.IsSystem {
|
|
<span class="badge" style="background-color: var(--accent-muted); color: var(--accent);">System</span>
|
|
}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
if section.IsSystem {
|
|
<button
|
|
data-action="restore-system-collection"
|
|
data-collection-name={ section.ID }
|
|
class="btn btn-secondary py-1 px-2.5 text-xs"
|
|
title={ "Restore " + section.Title + " to defaults" }
|
|
>
|
|
Restore
|
|
</button>
|
|
}
|
|
<label class="relative inline-flex items-center cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
class="sr-only peer"
|
|
if !ContainsString(hiddenCollections, section.ID) {
|
|
checked
|
|
}
|
|
/>
|
|
<div
|
|
class="w-11 h-6 rounded-full peer peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all"
|
|
style="background-color: var(--border-strong);"
|
|
></div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
<div class="mb-6">
|
|
<label class="block text-sm font-medium mb-2" style="color: var(--text-secondary)">
|
|
Items per Collection: <span id="items-count-display" class="font-bold" style="color: var(--text-primary)">{ itemsPerSection }</span>
|
|
</label>
|
|
<input
|
|
type="range"
|
|
min="10"
|
|
max="50"
|
|
step="5"
|
|
value={ itemsPerSection }
|
|
class="w-full h-2 rounded-lg appearance-none cursor-pointer"
|
|
style="background-color: var(--border-strong);"
|
|
data-input-action="update-items-count"
|
|
target="items-count-display"
|
|
/>
|
|
</div>
|
|
<div class="flex justify-end gap-3">
|
|
<button data-action="close-dashboard-settings" class="btn btn-secondary">Cancel</button>
|
|
<button data-action="save-dashboard-settings" class="btn btn-primary">Save Changes</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
}
|
|
|
|
// WeekStatsData is the dashboard's "this week" widget payload
|
|
// (server-rendered from the statistics service; nil = suppressed).
|
|
// Times rendered in the user's timezone at the server, per convention.
|
|
type WeekStatsData struct {
|
|
WeekMinutes int
|
|
WeekPages int
|
|
WeekSessions int
|
|
WeekBooks int
|
|
PrevMinutes int
|
|
ActiveToday bool
|
|
CurrentStreak int
|
|
LibraryID string // scope the widget was rendered for ("" = all libraries)
|
|
}
|
|
|
|
// BookStatsData is the per-book reading-stats card on the book detail
|
|
// page (nil = the viewer has no sessions on this book → hide it).
|
|
type BookStatsData struct {
|
|
TotalMinutes int
|
|
TotalPages int
|
|
SessionCount int
|
|
MaxProgress float64 // 0..1
|
|
Finished bool
|
|
LastSession string
|
|
FirstSession string
|
|
}
|
|
|
|
// DashboardWeekWidget renders the collapsible "This Week" strip. The
|
|
// collapsed summary row reads at a glance on every screen size; the
|
|
// expanded body holds the full grid. The details element re-fetches
|
|
// itself through htmx when the body sees a `week-refresh` event
|
|
// (dispatched by the library switcher), carrying the CURRENT
|
|
// #library-select value, so the widget tracks the switched library.
|
|
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
|
|
<details
|
|
id="week-widget"
|
|
class="card group"
|
|
hx-get="/dashboard/week-widget"
|
|
hx-trigger="week-refresh from:body"
|
|
hx-include="#library-select"
|
|
hx-swap="outerHTML"
|
|
>
|
|
<summary class="px-4 md:px-6 py-3 flex flex-wrap items-center gap-x-5 gap-y-1 cursor-pointer select-none list-none [&::-webkit-details-marker]:hidden">
|
|
<span class="flex items-center gap-2 font-bold tracking-tight" style="color: var(--text-primary)">
|
|
<span class="grid place-items-center h-6 w-6 rounded-md" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("chart", "h-4 w-4")
|
|
</span>
|
|
This Week
|
|
</span>
|
|
<span class="text-sm font-semibold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
|
|
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
|
|
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
|
|
<span class="text-sm" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
|
|
if week.CurrentStreak > 0 {
|
|
<span class="text-sm font-medium" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
|
|
}
|
|
<span class="ml-auto flex items-center gap-3">
|
|
<a href="/stats" class="text-xs font-medium hover:underline" style="color: var(--accent);" onclick="event.stopPropagation()">Full stats</a>
|
|
@Icon("chevron-down", "h-4 w-4 transition-transform group-open:rotate-180")
|
|
</span>
|
|
</summary>
|
|
<div class="px-4 md:px-6 pb-4 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
|
|
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
|
if activeLibType == "audiobooks" {
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Listening Time</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
|
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
|
</div>
|
|
} else {
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Reading Time</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</p>
|
|
<p class="text-xs mt-0.5" style="color: var(--text-secondary)">{ weekVsPrevLabel(week.WeekMinutes, week.PrevMinutes) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Pages</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekPages) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Sessions</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekSessions) }</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-xs uppercase tracking-wide" style="color: var(--text-secondary)">Books Touched</p>
|
|
<p class="text-2xl font-bold" style="color: var(--text-primary)">{ fmt.Sprintf("%d", week.WeekBooks) }</p>
|
|
</div>
|
|
}
|
|
</div>
|
|
<p class="text-xs mt-3" style="color: var(--text-secondary)">
|
|
Synced from your reading devices
|
|
if week.CurrentStreak > 0 {
|
|
if week.ActiveToday {
|
|
{ fmt.Sprintf(" · 🔥 %d-day streak — keep it going today", week.CurrentStreak) }
|
|
} else {
|
|
{ fmt.Sprintf(" · 🔥 %d-day streak — read today to keep it", week.CurrentStreak) }
|
|
}
|
|
}
|
|
</p>
|
|
</div>
|
|
</details>
|
|
}
|
|
|
|
// fmtReadingMinutes renders "42 min" / "1h 5min" spans.
|
|
func fmtReadingMinutes(minutes int) string {
|
|
if minutes >= 60 {
|
|
if minutes%60 == 0 {
|
|
return fmt.Sprintf("%dh", minutes/60)
|
|
}
|
|
return fmt.Sprintf("%dh %dmin", minutes/60, minutes%60)
|
|
}
|
|
return fmt.Sprintf("%d min", minutes)
|
|
}
|
|
|
|
func weekVsPrevLabel(minutes, prevMinutes int) string {
|
|
if prevMinutes <= 0 {
|
|
if minutes > 0 {
|
|
return "reading time"
|
|
}
|
|
return "no reading yet"
|
|
}
|
|
percent := float64(minutes-prevMinutes) / float64(prevMinutes) * 100
|
|
return fmt.Sprintf("%+.0f%% vs last week", percent)
|
|
}
|