feat(series): add series browse template, nav link, and clickable badge

Create templates/series.templ with:
- Library selector dropdown (sticky, same pattern as dashboard)
- Loading spinner overlay for AJAX library switching
- Series grid with stacked-cascade multi-cover cards
- Empty state when no series found
- Pagination with Previous/Next links
- SeriesCard sub-template linking to filtered bookshelf view

Add 'Series' nav link in header between 'All Books' and 'Collections'.

Make series badge on book detail page clickable, linking to
/bookshelf?series_filter=<name>&sort=series.

Add 'Continue Series' option to restore system collection modal.
This commit is contained in:
2026-05-08 20:27:15 -04:00
parent 004b761381
commit b83e319a34
8 changed files with 901 additions and 360 deletions
+124
View File
@@ -0,0 +1,124 @@
package templates
import (
"fmt"
"net/url"
)
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Series - Bookhoard</title>
<link href="/static/style.css" rel="stylesheet"/>
</head>
<body x-data="seriesPage" x-init="initSeriesPage()" class="theme-{ user.Theme }">
@Header(user, "/series")
<!-- Sticky Library Selector -->
<div class="sticky top-0 z-40 bg-opacity-95 backdrop-blur border-b" style="background-color: var(--bg-primary);">
<div class="w-full px-4 py-3 flex items-center justify-between">
<div class="flex items-center gap-4">
<label class="text-sm font-medium" style="color: var(--text-secondary)">Library:</label>
<select
id="library-select"
name="library_id"
class="px-4 py-2 rounded-lg border focus:ring-2 focus:ring-blue-500"
style="background-color: var(--bg-secondary); color: var(--text-primary);"
>
for _, lib := range libData {
if lib.ID == currentLibraryID {
<option value={ lib.ID } selected>{ lib.Name }</option>
} else {
<option value={ lib.ID }>{ lib.Name }</option>
}
}
</select>
</div>
</div>
</div>
<div
id="loading-spinner"
class="hidden fixed inset-0 bg-opacity-50 flex items-center justify-center z-50"
style="background-color: var(--bg-primary);"
>
<div class="animate-spin rounded-full h-12 w-12 border-b-2" style="border-color: var(--accent);"></div>
</div>
<!-- Series Grid -->
<div id="series-container">
<main class="w-full px-4 py-8">
<h1 class="text-3xl font-bold mb-6" style="color: var(--text-primary)">Series</h1>
if len(seriesList) == 0 {
<div id="series-empty" class="text-center py-16" style="color: var(--text-secondary)">
<div class="text-6xl mb-4">📚</div>
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
<p>Books with series metadata will appear here</p>
</div>
}
<div id="series-grid" class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6">
for _, series := range seriesList {
@SeriesCard(series, currentLibraryID)
}
</div>
<!-- Pagination -->
if totalPages > 1 {
<div id="series-pagination" class="flex justify-center items-center gap-4 mt-8">
if currentPage > 1 {
<a
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Previous
</a>
}
<span class="text-sm" style="color: var(--text-secondary)">
Page { fmt.Sprintf("%d", currentPage) } of { fmt.Sprintf("%d", totalPages) }
</span>
if currentPage < totalPages {
<a
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
class="px-4 py-2 rounded-lg border"
style="border-color: var(--border); color: var(--text-primary);"
>
Next
</a>
}
</div>
}
</main>
</div>
@ErrorToast(errorMessage)
</body>
</html>
}
templ SeriesCard(series SeriesCardData, libraryID string) {
<a href={ "/bookshelf?series_filter=" + url.QueryEscape(series.Name) + "&sort=series&library_id=" + libraryID } class="block">
<div class="series-card rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow" style="background-color: var(--bg-secondary);">
<div class={ "stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths)) }>
for i, cover := range series.CoverPaths {
<img
src={ cover }
class={ "cover-img cover-" + fmt.Sprintf("%d", i) }
alt={ series.Name }
loading="lazy"
onerror="this.src='/static/placeholder-book.svg'"
/>
}
if len(series.CoverPaths) == 0 {
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary);">
<span class="text-3xl">📚</span>
</div>
}
</div>
<div class="series-card-info px-3 py-2">
<h3 class="font-semibold text-sm line-clamp-2" style="color: var(--text-primary)">{ series.Name }</h3>
<p class="text-xs mt-1" style="color: var(--text-secondary)">
{ fmt.Sprintf("%d", series.BookCount) } of { fmt.Sprintf("%d", series.TotalInSeries) } books
</p>
</div>
</div>
</a>
}