Toolbar feedback round: the series search box now matches whatever the main search matches for a group — series NAME, any book's AUTHOR, and any book's TITLE inside the series (WHERE EXISTS per group-row, so counts stay true); the separate author input is gone from the toolbar (the author= param stays live for the JSON API and deep links). The sort control becomes a base-key select (Date added/Name/Author/Books/ Progress/Last read) plus a chevron direction toggle that flips and resubmits; sort/dir compose through ResolveSeriesSort (full registry keys keep working for back-compat, unknown base keys fall to recent). The search/apply button moved next to the search box as requested, and default URLs stay clean (sort/dir only emitted once the user chooses).
265 lines
9.1 KiB
Templ
265 lines
9.1 KiB
Templ
package templates
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
)
|
|
|
|
// seriesSortOptions is the <select> mirror of the SQL CASE cascade in
|
|
// GetDistinctSeries and the registry in services.ResolveSeriesSort —
|
|
// every value here must have a matching base sort in
|
|
// services.seriesBaseSorts (the direction toggle composes the full
|
|
// registry key).
|
|
var seriesSortOptions = []struct {
|
|
Value string
|
|
Label string
|
|
}{
|
|
{"recent", "Date added"},
|
|
{"name", "Name"},
|
|
{"author", "Author"},
|
|
{"books", "Books"},
|
|
{"progress", "Progress"},
|
|
{"last_read", "Last read"},
|
|
}
|
|
|
|
// seriesStatusOptions is the progress-filter <select> (mirror of the
|
|
// status registry in services.ResolveSeriesSort's sibling map).
|
|
var seriesStatusOptions = []struct {
|
|
Value string
|
|
Label string
|
|
}{
|
|
{"", "Any progress"},
|
|
{"unstarted", "Unstarted"},
|
|
{"reading", "In progress"},
|
|
{"finished", "Finished"},
|
|
}
|
|
|
|
// seriesPageURL builds a /series URL carrying the full page state so
|
|
// pagination and clear-filters links never drop sort/filter state.
|
|
func seriesPageURL(state SeriesPageState, libraryID string, page int) string {
|
|
vals := url.Values{}
|
|
if libraryID != "" {
|
|
vals.Set("library_id", libraryID)
|
|
}
|
|
if state.Sort != "" {
|
|
vals.Set("sort", state.Sort)
|
|
}
|
|
if state.Dir != "" {
|
|
vals.Set("dir", state.Dir)
|
|
}
|
|
if state.Query != "" {
|
|
vals.Set("q", state.Query)
|
|
}
|
|
if state.Status != "" {
|
|
vals.Set("status", state.Status)
|
|
}
|
|
vals.Set("page", strconv.Itoa(page))
|
|
return "/series?" + vals.Encode()
|
|
}
|
|
|
|
// hasActiveSeriesFilters reports whether the state deviates from the
|
|
// page's defaults — used to swap the empty state for a
|
|
// "no matches" + clear-filters variant.
|
|
func hasActiveSeriesFilters(state SeriesPageState) bool {
|
|
return state.Query != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
|
|
}
|
|
|
|
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, 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"/>
|
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
|
|
</head>
|
|
<body x-data="seriesPage" x-init="initSeriesPage()" class={ "theme-" + user.Theme }>
|
|
@Header(user, "/series")
|
|
@LibrarySwitcher(libData, currentLibraryID, "", nil)
|
|
<div id="series-container">
|
|
<main class="mx-auto container px-4 sm:px-6 lg:px-8 py-8">
|
|
<div class="mb-6 flex items-center gap-3">
|
|
<span class="grid place-items-center h-10 w-10 rounded-xl" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("layers", "h-5 w-5")
|
|
</span>
|
|
<div>
|
|
<h1 class="text-2xl font-bold tracking-tight" style="color: var(--text-primary)">Series</h1>
|
|
<p class="text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary)">Grouped by series metadata</p>
|
|
</div>
|
|
</div>
|
|
<form
|
|
id="series-filter-form"
|
|
method="get"
|
|
action="/series"
|
|
class="mb-6 flex flex-wrap items-center gap-3"
|
|
>
|
|
<input type="hidden" name="library_id" value={ currentLibraryID }/>
|
|
<div class="relative flex-1 min-w-48">
|
|
<span class="absolute left-3 top-1/2 -translate-y-1/2 pointer-events-none" style="color: var(--text-secondary)">
|
|
@Icon("search", "h-4 w-4")
|
|
</span>
|
|
<input
|
|
type="text"
|
|
name="q"
|
|
value={ state.Query }
|
|
placeholder="Search series, authors, titles…"
|
|
class="input w-full pl-9"
|
|
aria-label="Search series by name, author, or title"
|
|
/>
|
|
</div>
|
|
<button type="submit" class="btn btn-secondary" aria-label="Search">
|
|
@Icon("search", "h-4 w-4")
|
|
<span>Search</span>
|
|
</button>
|
|
<select
|
|
name="status"
|
|
class="input w-auto cursor-pointer"
|
|
aria-label="Filter series by your progress"
|
|
@change="$el.form.requestSubmit()"
|
|
>
|
|
for _, opt := range seriesStatusOptions {
|
|
<option value={ opt.Value } selected?={ state.Status == opt.Value }>{ opt.Label }</option>
|
|
}
|
|
</select>
|
|
<select
|
|
name="sort"
|
|
class="input w-auto cursor-pointer"
|
|
aria-label="Sort series by"
|
|
@change="$el.form.requestSubmit()"
|
|
>
|
|
for _, opt := range seriesSortOptions {
|
|
<option value={ opt.Value } selected?={ (state.Sort == "" && opt.Value == "recent") || state.Sort == opt.Value }>{ opt.Label }</option>
|
|
}
|
|
</select>
|
|
<input type="hidden" name="dir" value={ state.Dir } x-ref="sortDir"/>
|
|
<button
|
|
type="button"
|
|
class="btn btn-secondary px-3"
|
|
aria-label="Toggle sort direction"
|
|
title="Toggle sort direction"
|
|
@click="flipSortDir()"
|
|
>
|
|
if state.Dir == "asc" {
|
|
@Icon("chevron-up", "h-4 w-4")
|
|
} else {
|
|
@Icon("chevron-down", "h-4 w-4")
|
|
}
|
|
</button>
|
|
</form>
|
|
if len(seriesList) == 0 {
|
|
if hasActiveSeriesFilters(state) {
|
|
<div id="series-empty" class="card text-center py-16 px-6">
|
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("search", "h-7 w-7")
|
|
</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Match</h3>
|
|
<p class="mb-4" style="color: var(--text-secondary)">Nothing matches the current search or filters</p>
|
|
<a href={ seriesPageURL(SeriesPageState{}, currentLibraryID, 1) } class="btn btn-secondary">
|
|
Clear filters
|
|
</a>
|
|
</div>
|
|
} else {
|
|
<div id="series-empty" class="card text-center py-16 px-6">
|
|
<div class="grid place-items-center h-14 w-14 rounded-2xl mx-auto mb-4" style="background-color: var(--accent-muted); color: var(--accent);">
|
|
@Icon("layers", "h-7 w-7")
|
|
</div>
|
|
<h3 class="text-xl font-semibold mb-2" style="color: var(--text-primary)">No Series Found</h3>
|
|
<p style="color: var(--text-secondary)">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)
|
|
}
|
|
</div>
|
|
if totalPages > 1 {
|
|
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
|
|
if currentPage > 1 {
|
|
<a
|
|
href={ seriesPageURL(state, currentLibraryID, currentPage-1) }
|
|
class="btn btn-secondary"
|
|
>
|
|
@Icon("chevron-left", "h-4 w-4")
|
|
<span>Previous</span>
|
|
</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={ seriesPageURL(state, currentLibraryID, currentPage+1) }
|
|
class="btn btn-secondary"
|
|
>
|
|
<span>Next</span>
|
|
@Icon("chevron-right", "h-4 w-4")
|
|
</a>
|
|
}
|
|
</div>
|
|
}
|
|
</main>
|
|
</div>
|
|
@ErrorToast(errorMessage)
|
|
</body>
|
|
</html>
|
|
}
|
|
|
|
templ SeriesCard(series SeriesCardData) {
|
|
<a href={ "/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID) } class="block">
|
|
<div class="series-card card rounded-2xl overflow-hidden cursor-pointer" style="box-shadow: var(--shadow-card);">
|
|
<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"
|
|
|
|
/>
|
|
}
|
|
if len(series.CoverPaths) == 0 {
|
|
<div class="cover-img cover-0 flex items-center justify-center" style="background: var(--bg-primary); color: var(--text-secondary);">
|
|
@Icon("book", "h-8 w-8 opacity-60")
|
|
</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>
|
|
if series.ReadCount > 0 {
|
|
<div class="mt-1.5 h-1 rounded-full overflow-hidden" style="background: var(--bg-primary);">
|
|
<div
|
|
class="h-full rounded-full"
|
|
style={ fmt.Sprintf("width: %d%%; background-color: var(--accent);", readPercent(series.ReadCount, series.BookCount)) }
|
|
></div>
|
|
</div>
|
|
<p class="text-[10px] mt-0.5" style="color: var(--text-secondary)">
|
|
{ fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount) }
|
|
</p>
|
|
}
|
|
</div>
|
|
</div>
|
|
</a>
|
|
}
|
|
|
|
// readPercent converts (finished, total) into a clamped 0..100 int for
|
|
// the card's progress bar.
|
|
func readPercent(finished, total int64) int {
|
|
if total <= 0 {
|
|
return 0
|
|
}
|
|
pct := int(finished * 100 / total)
|
|
if pct < 0 {
|
|
return 0
|
|
}
|
|
if pct > 100 {
|
|
return 100
|
|
}
|
|
return pct
|
|
}
|