Files
bookhoard/templates/dashboard.templ
T
John O'Keefe 8c97f59453
Release / build-and-push (push) Successful in 3m11s
feat(dashboard): move This Week stats into the library bar as a popover
The "This Week" reading-stats strip leaves its full-width row below the
subbar and now lives inside the library bar itself, between the switcher
and the customize/refresh actions. Expanding it opens an absolutely
positioned popover that FLOATS over the collections — opening the stats
no longer shifts the page down.

- LibrarySwitcher gains an optional `center` component slot rendered
  between the left group and the actions group (nil = unused). Series
  and collections call sites pass nil and render byte-identical output.
- DashboardWeekWidget redesigned for the slot: compact inline summary
  (icon, "This Week", minutes; pages/sessions/books/streak reveal at
  md/lg/xl) and a card popover panel with the labeled grid, sync note,
  and the Full stats link moved in from the old summary row.
- Panel anchoring is responsive: below `sm` it anchors to the sticky
  bar itself (details goes static; bar-anchored left-4/right-4, i.e.
  screen-centered); at `sm`+ the details is relative again and the
  panel centers on the widget (w-[30rem], translate-x centering).
- On phones the summary collapses to the chart icon + chevron (bar row
  width is scarce); "This Week"/minutes reveal at `sm`, and the
  "Your Library" title hides below `sm` to keep the row from
  overlapping. Icon-only toggle carries an aria-label.
- web/src/dashboard.ts: click-outside and Escape close the popover,
  re-querying #week-widget per event because htmx replaces it on
  library switches. Rendering untouched (behavior-only island).
- id="week-widget" and all htmx wiring unchanged: week-refresh refetch
  + the /dashboard/week-widget fragment still drive library-scoped
  refreshes behind the switch fade; stats-off suppression now yields an
  empty slot via weekWidgetComponent instead of a hidden strip.
- web/static/style.css rebuilt for the new utilities.

Known behavior (unchanged or deliberate): the bar is sticky, so stats
stay visible while scrolling; a library switch re-renders the details
closed; scopes with no current-week reading show zeros (e.g. Manga).
2026-10-27 10:22:13 -04:00

440 lines
18 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, unlinkedCount int) {
<!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", weekWidgetComponent(week, activeLibType), 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 unlinkedCount > 0 {
<div class="mx-auto container px-4 sm:px-6 lg:px-8 pt-4">
<a
href="/unlinked-books"
class="card p-4 flex items-center gap-3 transition-colors hover:bg-surface-hover"
style="border-left-width: 4px; border-left-color: var(--status-warning);"
>
<span class="grid place-items-center h-8 w-8 rounded-lg shrink-0" style="background-color: color-mix(in srgb, var(--status-warning) 16%, transparent); color: var(--status-warning);">
@Icon("alert", "h-4 w-4")
</span>
<span class="flex-1 text-sm" style="color: var(--text-primary)">
<span class="font-semibold">{ fmt.Sprintf("%d", unlinkedCount) }</span>
{ pluralizeBooks(unlinkedCount) } on your devices could not be identified — reading progress for them is paused until they are linked.
</span>
<span class="text-sm font-medium shrink-0" style="color: var(--accent)">Review →</span>
</a>
</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"
/>
} 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
}
// weekWidgetComponent adapts the optional week stats into the
// LibrarySwitcher's center slot. nil (stats unavailable or the stats
// service is off) = the slot stays empty, exactly as when the widget
// used to be suppressed entirely.
func weekWidgetComponent(week *WeekStatsData, activeLibType string) templ.Component {
if week == nil {
return nil
}
return DashboardWeekWidget(*week, activeLibType)
}
// DashboardWeekWidget renders the "This Week" stats that live in the
// library bar's center slot (LibrarySwitcher's `center` component).
// The summary is a compact inline row whose numbers reveal themselves
// as width allows; the expanded body is an absolutely positioned
// popover that FLOATS over the page content — opening it never shifts
// the collections below. 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. Visible at every width — on
// narrow screens the summary collapses to the chart icon + chevron
// (tap target, aria-labeled; "This Week" + minutes reveal at `sm`,
// pages/sessions/books at md/lg/xl). The panel anchors to the
// BAR (full-width under it) below `sm` — the widget is too far left on
// phones for widget-centered anchoring — and centers on the widget at
// `sm` and up.
templ DashboardWeekWidget(week WeekStatsData, activeLibType string) {
<details
id="week-widget"
class="group sm:relative"
hx-get="/dashboard/week-widget"
hx-trigger="week-refresh from:body"
hx-include="#library-select"
hx-swap="outerHTML"
>
<summary class="flex items-center gap-2 cursor-pointer select-none list-none rounded-lg px-2 py-1 hover:bg-surface-hover [&::-webkit-details-marker]:hidden" aria-label="Reading stats for this week">
<span class="grid place-items-center h-6 w-6 rounded-md shrink-0" style="background-color: var(--accent-muted); color: var(--accent);">
@Icon("chart", "h-4 w-4")
</span>
<span class="text-sm font-bold tracking-tight whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">This Week</span>
<span class="text-sm font-semibold whitespace-nowrap hidden sm:inline" style="color: var(--text-primary)">{ fmtReadingMinutes(week.WeekMinutes) }</span>
<span class="text-xs whitespace-nowrap hidden md:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d pages", week.WeekPages) }</span>
<span class="text-xs whitespace-nowrap hidden lg:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d sessions", week.WeekSessions) }</span>
<span class="text-xs whitespace-nowrap hidden xl:inline" style="color: var(--text-secondary)">{ fmt.Sprintf("%d books", week.WeekBooks) }</span>
if week.CurrentStreak > 0 {
<span class="text-xs font-medium whitespace-nowrap hidden lg:inline" style="color: var(--accent)">🔥 { fmt.Sprintf("%d", week.CurrentStreak) }-day streak</span>
}
@Icon("chevron-down", "h-4 w-4 shrink-0 transition-transform group-open:rotate-180")
</summary>
<div class="card absolute top-full left-4 right-4 sm:left-1/2 sm:right-auto sm:-translate-x-1/2 mt-2 sm:w-[30rem] max-w-[calc(100vw-2rem)] z-40 p-4 md:px-6 md:py-5" style="box-shadow: var(--shadow-pop);">
<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>
<div class="flex items-end justify-between gap-4 mt-3 pt-3 border-t" style="border-color: color-mix(in srgb, var(--text-primary) 6%, transparent);">
<p class="text-xs" 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>
<a href="/stats" class="inline-flex items-center gap-1 text-xs font-medium hover:underline shrink-0 whitespace-nowrap" style="color: var(--accent);">Full stats
@Icon("arrow-right", "h-4 w-4")
</a>
</div>
</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)
}