The htmx self-refresh was bound to an element that was itself being destroyed: the widget rendered inside #collections-container, and the switcher's renderDashboardCollections replaces that container's whole innerHTML on every library switch — listener and all — before any refresh could fire. Hence 'widget disappears instead of updating'. The widget now renders in its own strip between the switcher subbar and the sections container, which the switcher never touches. The week-refresh htmx re-fetch (with the current #library-select value) then has a living target and re-scopes the numbers per library. Verified: the subbar's refresh button drives the identical re-render path and the widget persists with its data.
371 lines
14 KiB
Templ
371 lines
14 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, 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)
|
|
</div>
|
|
}
|
|
<main id="collections-container" class="w-full px-4 sm:px-6 lg:px-8 py-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"
|
|
/>
|
|
}
|
|
</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 {
|
|
<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) {
|
|
<details
|
|
id="week-widget"
|
|
class="card mb-8 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">
|
|
<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)
|
|
}
|