Release / build-and-push (push) Successful in 4m54s
Book cards on the library (all-books) page were oversized at full window width, and both the all-books and series grids showed only 2 columns on phones, making covers unnecessarily large on mobile. Shift the all-books column ladder up one at every breakpoint (2/4/5/6 -> 3/5/6/7) so the responsive reflow the grid is liked for is preserved while every window size gets smaller cards; the series grid gains a column at mobile only (2 -> 3), leaving md/lg untouched per request. Dashboard carousel sizing (w-36/w-40) and the series stacked-covers component are deliberately unchanged. templ codegen and the tailwind build output commit with the source change that produced them (repo convention).
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-3 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
|
|
}
|