feat(series): sort and filter the series page
The /series page rendered its grid in one fixed order (newest first)
with no way to find a series beyond eyeballing cards. The page now has
a toolbar — series-name search, author filter, viewer-progress filter
and a 12-key sort select — wired as a plain GET form (the page's
existing model: the URL is the single source of truth, and the library
switcher now carries q/author/status/sort along on switch).
Extensibility is deliberate (user request): sorts live in a registry
(services.ResolveSeriesSort) mirrored by a CASE cascade in
GetDistinctSeries and a <select> in series.templ — a new sort is one
entry in each. The sort value is a BOUND PARAM compared inside the
cascade, never interpolated; unknown keys resolve to the default.
Filters: series-name matches in WHERE (the group key, so counts stay
true); author (BOOL_OR any-book match) and status (finished/reading/
unstarted vs the viewer's progress, same 0.999 threshold as the
filter_query status arms) in HAVING so COUNT(*) keeps reporting the
whole series. GetDistinctSeriesCount shares the JOIN + HAVING so
pagination totals match. The reading_progress join can't multiply rows
(the table is UNIQUE per media_item+user).
Cards gain the viewer's finished-count ("X of N read" + progress bar),
and the empty state distinguishes "no series at all" from "no matches —
clear filters". The JSON /api/series endpoint takes the same params and
gains read_count/last_read_at additively.
Integration test pins every sort key against a three-series fixture
with distinct created/author/count/progress answers, the filter matrix,
composition, unknown-sort fallback, card read-counts and the JSON
contract — all as the fresh regular user so the live reader's data is
untouched.
This commit is contained in:
1 parent
79f4d668f1
commit
d1f9ad3b25
10 files changed
+1093
-129
No files matched your search
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSeriesSortAndFilter pins the series page's sort/filter contract
|
||||
// (2026-10-27 feature): the /series SSR page and the /api/series JSON
|
||||
// endpoint share one sort registry (services.ResolveSeriesSort) and one
|
||||
// SQL CASE cascade (GetDistinctSeries). The fixture gives three series
|
||||
// DISTINCT answers per sort key so ordering bugs can't hide:
|
||||
//
|
||||
// created author books viewer progress
|
||||
// Probe Zebra 01-03 Zeta Zed 2 1 of 2 read (01-09) → reading
|
||||
// Probe Mango 01-02 Alpha One 3 3 of 3 read (01-10) → finished
|
||||
// Probe Apple 01-01 Midway Two 1 none → unstarted
|
||||
//
|
||||
// All viewer progress belongs to the FRESH regular user
|
||||
// (getRegularUserID) and cascades away with the test library — the
|
||||
// shared admin's reading data is never touched.
|
||||
func TestSeriesSortAndFilter(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("Skipping integration test in short mode")
|
||||
}
|
||||
setup := setupTestServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
libID := createTestLibraryWithFolder(t, setup.Server, setup.Token, "Test Series Filter Library", false)
|
||||
viewerID := getRegularUserID(t, setup.DB)
|
||||
|
||||
type seriesFixture struct {
|
||||
name string
|
||||
author string
|
||||
createdAt string
|
||||
books []struct {
|
||||
title string
|
||||
percentage string
|
||||
lastReadAt string
|
||||
}
|
||||
}
|
||||
fixtures := []seriesFixture{
|
||||
{
|
||||
name: "Probe Zebra Series", author: "Zeta Zed", createdAt: "2026-01-03 12:00:00+00",
|
||||
books: []struct {
|
||||
title string
|
||||
percentage string
|
||||
lastReadAt string
|
||||
}{
|
||||
{title: "Zebra Book 1", percentage: "1.0", lastReadAt: "2026-01-09 08:00:00+00"},
|
||||
{title: "Zebra Book 2", percentage: "0.5", lastReadAt: "2026-01-09 08:00:00+00"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Probe Mango Series", author: "Alpha One", createdAt: "2026-01-02 12:00:00+00",
|
||||
books: []struct {
|
||||
title string
|
||||
percentage string
|
||||
lastReadAt string
|
||||
}{
|
||||
{title: "Mango Book 1", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||
{title: "Mango Book 2", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||
{title: "Mango Book 3", percentage: "1.0", lastReadAt: "2026-01-10 08:00:00+00"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Probe Apple Series", author: "Midway Two", createdAt: "2026-01-01 12:00:00+00",
|
||||
books: []struct {
|
||||
title string
|
||||
percentage string
|
||||
lastReadAt string
|
||||
}{
|
||||
{title: "Apple Book 1", percentage: "0", lastReadAt: ""},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
stripComments := regexp.MustCompile(`<!--.*?-->`)
|
||||
// Card headings only: the empty-state panels use different h3
|
||||
// classes ("text-xl font-semibold mb-2"), and "No Series Match"
|
||||
// must not read as a rendered series.
|
||||
nameRegex := regexp.MustCompile(`<h3 class="font-semibold text-sm line-clamp-2"[^>]*>([^<]+)</h3>`)
|
||||
|
||||
renderedNames := func(query string) []string {
|
||||
code, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID+query, setup.RegularToken)
|
||||
if code != 200 {
|
||||
t.Logf("series request %q -> %d; body head: %.500s", query, code, body)
|
||||
}
|
||||
require.Equal(t, 200, code)
|
||||
var names []string
|
||||
for _, m := range nameRegex.FindAllStringSubmatch(stripComments.ReplaceAllString(body, ""), -1) {
|
||||
names = append(names, strings.TrimSpace(m[1]))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_, _ = setup.DBPool.Exec(ctx, "DELETE FROM libraries WHERE id = $1", libID)
|
||||
})
|
||||
|
||||
for _, fx := range fixtures {
|
||||
for _, b := range fx.books {
|
||||
var itemID string
|
||||
err := setup.DBPool.QueryRow(ctx,
|
||||
`INSERT INTO media_items (library_id, title, file_path, series, authors, created_at, imported_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, $6) RETURNING id`,
|
||||
libID, b.title, fmt.Sprintf("/tmp/test-series-filter/%s.epub", b.title), fx.name, []string{fx.author}, fx.createdAt,
|
||||
).Scan(&itemID)
|
||||
require.NoError(t, err)
|
||||
if b.percentage != "0" && b.percentage != "" {
|
||||
_, err = setup.DBPool.Exec(ctx,
|
||||
`INSERT INTO reading_progress (media_item_id, user_id, percentage, last_read_at)
|
||||
VALUES ($1, $2, $3::float, $4::timestamptz)`,
|
||||
itemID, viewerID, b.percentage, b.lastReadAt,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("default sort is recently added", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames(""))
|
||||
})
|
||||
|
||||
t.Run("every sort key orders the grid", func(t *testing.T) {
|
||||
cases := []struct {
|
||||
sort string
|
||||
want []string
|
||||
}{
|
||||
{"sort=recent", []string{"Zebra", "Mango", "Apple"}},
|
||||
{"sort=recent_asc", []string{"Apple", "Mango", "Zebra"}},
|
||||
{"sort=name_asc", []string{"Apple", "Mango", "Zebra"}},
|
||||
{"sort=name_desc", []string{"Zebra", "Mango", "Apple"}},
|
||||
{"sort=author_asc", []string{"Mango", "Apple", "Zebra"}},
|
||||
{"sort=author_desc", []string{"Zebra", "Apple", "Mango"}},
|
||||
{"sort=books_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||
{"sort=books_asc", []string{"Apple", "Zebra", "Mango"}},
|
||||
{"sort=progress_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||
{"sort=progress_asc", []string{"Apple", "Zebra", "Mango"}},
|
||||
{"sort=last_read_desc", []string{"Mango", "Zebra", "Apple"}},
|
||||
{"sort=last_read_asc", []string{"Zebra", "Mango", "Apple"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := renderedNames("&" + tc.sort)
|
||||
require.Len(t, got, 3, tc.sort)
|
||||
for i, want := range tc.want {
|
||||
assert.Contains(t, got[i], want, "%s: position %d", tc.sort, i)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown sort falls back to the default", func(t *testing.T) {
|
||||
// Unknown keys resolve to "recent" in the registry; the SQL never
|
||||
// interpolates the value (bound-param CASE cascade), so a hostile
|
||||
// sort value is inert by construction.
|
||||
assert.Equal(t, []string{"Probe Zebra Series", "Probe Mango Series", "Probe Apple Series"}, renderedNames("&sort=bogus-key"))
|
||||
})
|
||||
|
||||
t.Run("name query filters", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=Mango"))
|
||||
// ILIKE is case-insensitive and substring-shaped.
|
||||
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=mango"))
|
||||
})
|
||||
|
||||
t.Run("author query filters on any book's author", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&author=alpha"))
|
||||
assert.Empty(t, renderedNames("&author=nobody"))
|
||||
})
|
||||
|
||||
t.Run("status filter matches the viewer's progress", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&status=finished"), "all books finished")
|
||||
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&status=reading"), "some book mid-read")
|
||||
assert.Equal(t, []string{"Probe Apple Series"}, renderedNames("&status=unstarted"), "no progress anywhere")
|
||||
})
|
||||
|
||||
t.Run("filters compose", func(t *testing.T) {
|
||||
assert.Equal(t, []string{"Probe Zebra Series"}, renderedNames("&q=probe&status=reading"))
|
||||
assert.Equal(t, []string{"Probe Mango Series"}, renderedNames("&q=probe&status=finished&sort=progress_desc"))
|
||||
})
|
||||
|
||||
t.Run("cards carry the viewer's read count", func(t *testing.T) {
|
||||
_, body := ssrGet(t, setup.Server.URL+"/series?library_id="+libID, setup.RegularToken)
|
||||
assert.Contains(t, body, "3 of 3 read")
|
||||
assert.Contains(t, body, "1 of 2 read")
|
||||
assert.NotContains(t, body, "of 1 read", "unstarted series must not render a progress line")
|
||||
})
|
||||
|
||||
t.Run("JSON API shares the sort/filter contract", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", setup.Server.URL+"/api/series?library_id="+libID+"&sort=author_asc&status=finished", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Authorization", "Bearer "+setup.RegularToken)
|
||||
resp, err := (&http.Client{}).Do(req)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, 200, resp.StatusCode)
|
||||
|
||||
var payload struct {
|
||||
Series []struct {
|
||||
Name string `json:"name"`
|
||||
ReadCount int64 `json:"read_count"`
|
||||
} `json:"series"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(&payload))
|
||||
require.Len(t, payload.Series, 1)
|
||||
assert.Equal(t, "Probe Mango Series", payload.Series[0].Name)
|
||||
assert.Equal(t, int64(3), payload.Series[0].ReadCount)
|
||||
assert.Equal(t, 1, payload.Total)
|
||||
})
|
||||
}
|
||||
@@ -5266,21 +5266,59 @@ func (q *Queries) GetDictionaryEntry(ctx context.Context, word string) (Dictiona
|
||||
const GetDistinctSeries = `-- name: GetDistinctSeries :many
|
||||
SELECT library_id, series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
MAX(created_at) as last_entry_at,
|
||||
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
||||
MAX(rp.last_read_at) AS last_read_at
|
||||
FROM media_items
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = $1::uuid
|
||||
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
||||
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
|
||||
GROUP BY library_id, series
|
||||
ORDER BY MAX(created_at) DESC, library_id, series
|
||||
LIMIT $4 OFFSET $3
|
||||
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
||||
AND (
|
||||
$6::text IS NULL
|
||||
OR CASE $6::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE($7::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'name_asc' THEN series END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'name_desc' THEN series END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'progress_desc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'progress_asc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END ASC NULLS LAST,
|
||||
CASE WHEN $7::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
||||
CASE WHEN $7::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
||||
library_id, series
|
||||
LIMIT $9 OFFSET $8
|
||||
`
|
||||
|
||||
type GetDistinctSeriesParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
||||
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
||||
Status pgtype.Text `db:"status" json:"status"`
|
||||
Sort pgtype.Text `db:"sort" json:"sort"`
|
||||
Offset pgtype.Int4 `db:"offset" json:"offset"`
|
||||
Limit pgtype.Int4 `db:"limit" json:"limit"`
|
||||
}
|
||||
@@ -5291,20 +5329,35 @@ type GetDistinctSeriesRow struct {
|
||||
BookCount int64 `db:"book_count" json:"book_count"`
|
||||
TotalInSeries interface{} `db:"total_in_series" json:"total_in_series"`
|
||||
LastEntryAt interface{} `db:"last_entry_at" json:"last_entry_at"`
|
||||
FinishedCount int64 `db:"finished_count" json:"finished_count"`
|
||||
LastReadAt interface{} `db:"last_read_at" json:"last_read_at"`
|
||||
}
|
||||
|
||||
// Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
// (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
// filter enforces per-user library visibility at the source.
|
||||
// Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
||||
// transaction share a single NOW() timestamp, leaving many groups tied on
|
||||
// MAX(created_at). Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
// keys make the sort total and deterministic.
|
||||
//
|
||||
// Series-page sort/filter contract (extensible registry in
|
||||
// services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||
// it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||
// and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||
// series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||
// join can't multiply rows: reading_progress is UNIQUE per
|
||||
// media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||
// cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||
// REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||
// transaction share a single NOW() timestamp, leaving many groups tied
|
||||
// on MAX(created_at), and Postgres does not guarantee tie order between
|
||||
// executions, so page boundaries could repeat or drop series.
|
||||
func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesParams) ([]GetDistinctSeriesRow, error) {
|
||||
rows, err := q.db.Query(ctx, GetDistinctSeries,
|
||||
arg.ViewerUserID,
|
||||
arg.LibraryID,
|
||||
arg.VisibleLibraryIds,
|
||||
arg.SeriesQuery,
|
||||
arg.AuthorQuery,
|
||||
arg.Status,
|
||||
arg.Sort,
|
||||
arg.Offset,
|
||||
arg.Limit,
|
||||
)
|
||||
@@ -5321,6 +5374,8 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
||||
&i.BookCount,
|
||||
&i.TotalInSeries,
|
||||
&i.LastEntryAt,
|
||||
&i.FinishedCount,
|
||||
&i.LastReadAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -5333,22 +5388,54 @@ func (q *Queries) GetDistinctSeries(ctx context.Context, arg GetDistinctSeriesPa
|
||||
}
|
||||
|
||||
const GetDistinctSeriesCount = `-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
||||
SELECT COUNT(*)::int FROM (
|
||||
SELECT library_id, series
|
||||
FROM media_items
|
||||
WHERE ($1::uuid IS NULL OR library_id = $1::uuid)
|
||||
AND ($2::uuid[] IS NULL OR library_id = ANY($2::uuid[]))
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = $1::uuid
|
||||
WHERE ($2::uuid IS NULL OR library_id = $2::uuid)
|
||||
AND ($3::uuid[] IS NULL OR library_id = ANY($3::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND ($4::text IS NULL OR series ILIKE '%' || $4::text || '%')
|
||||
GROUP BY library_id, series
|
||||
HAVING ($5::text IS NULL OR BOOL_OR(author ILIKE '%' || $5::text || '%'))
|
||||
AND (
|
||||
$6::text IS NULL
|
||||
OR CASE $6::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
) groups
|
||||
`
|
||||
|
||||
type GetDistinctSeriesCountParams struct {
|
||||
ViewerUserID pgtype.UUID `db:"viewer_user_id" json:"viewer_user_id"`
|
||||
LibraryID pgtype.UUID `db:"library_id" json:"library_id"`
|
||||
VisibleLibraryIds []pgtype.UUID `db:"visible_library_ids" json:"visible_library_ids"`
|
||||
SeriesQuery pgtype.Text `db:"series_query" json:"series_query"`
|
||||
AuthorQuery pgtype.Text `db:"author_query" json:"author_query"`
|
||||
Status pgtype.Text `db:"status" json:"status"`
|
||||
}
|
||||
|
||||
// Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||
// HAVING so the pagination total matches what the page query returns
|
||||
// under the same filters (Sort is a page-ORDER concern, not a set
|
||||
// membership one, so it is absent here by design).
|
||||
func (q *Queries) GetDistinctSeriesCount(ctx context.Context, arg GetDistinctSeriesCountParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, GetDistinctSeriesCount, arg.LibraryID, arg.VisibleLibraryIds)
|
||||
row := q.db.QueryRow(ctx, GetDistinctSeriesCount,
|
||||
arg.ViewerUserID,
|
||||
arg.LibraryID,
|
||||
arg.VisibleLibraryIds,
|
||||
arg.SeriesQuery,
|
||||
arg.AuthorQuery,
|
||||
arg.Status,
|
||||
)
|
||||
var column_1 int32
|
||||
err := row.Scan(&column_1)
|
||||
return column_1, err
|
||||
|
||||
@@ -4071,32 +4071,94 @@ ORDER BY mi.created_at DESC;
|
||||
-- Series are grouped PER LIBRARY: the same series name in two libraries
|
||||
-- (novels vs manga adaptations, LN splits) stays two entries. Visible
|
||||
-- filter enforces per-user library visibility at the source.
|
||||
--
|
||||
-- Series-page sort/filter contract (extensible registry in
|
||||
-- services.ResolveSeriesSort): SeriesQuery is a series-NAME substring so
|
||||
-- it filters in WHERE (the group key — counts stay true); AuthorQuery
|
||||
-- and Status filter via HAVING so COUNT(*) keeps reporting the whole
|
||||
-- series. ViewerUserId scopes the per-user progress aggregates (the rp
|
||||
-- join can't multiply rows: reading_progress is UNIQUE per
|
||||
-- media_item+user). Sort is a BOUND PARAM compared inside the CASE
|
||||
-- cascade — never interpolated — and the GROUP BY tiebreaker stays
|
||||
-- REQUIRED for LIMIT/OFFSET determinism: bulk imports in one
|
||||
-- transaction share a single NOW() timestamp, leaving many groups tied
|
||||
-- on MAX(created_at), and Postgres does not guarantee tie order between
|
||||
-- executions, so page boundaries could repeat or drop series.
|
||||
SELECT library_id, series, COUNT(*) as book_count,
|
||||
MAX(series_count) as total_in_series,
|
||||
MAX(created_at) as last_entry_at
|
||||
MAX(created_at) as last_entry_at,
|
||||
COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) AS finished_count,
|
||||
MAX(rp.last_read_at) AS last_read_at
|
||||
FROM media_items
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
|
||||
GROUP BY library_id, series
|
||||
-- Tiebreaker REQUIRED for LIMIT/OFFSET pagination: bulk imports in one
|
||||
-- transaction share a single NOW() timestamp, leaving many groups tied on
|
||||
-- MAX(created_at). Postgres does not guarantee tie order between
|
||||
-- executions, so page boundaries could repeat or drop series. The GROUP BY
|
||||
-- keys make the sort total and deterministic.
|
||||
ORDER BY MAX(created_at) DESC, library_id, series
|
||||
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
||||
AND (
|
||||
sqlc.narg('status')::text IS NULL
|
||||
OR CASE sqlc.narg('status')::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
ORDER BY
|
||||
CASE WHEN COALESCE(sqlc.narg('sort')::text, 'recent') = 'recent' THEN MAX(created_at) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'recent_asc' THEN MAX(created_at) END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'name_asc' THEN series END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'name_desc' THEN series END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'author_asc' THEN MIN(COALESCE(author, '')) END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'author_desc' THEN MIN(COALESCE(author, '')) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'books_desc' THEN COUNT(*) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'books_asc' THEN COUNT(*) END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'progress_desc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'progress_asc'
|
||||
THEN (COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999))::float / NULLIF(COUNT(*), 0)
|
||||
END ASC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'last_read_desc' THEN MAX(rp.last_read_at) END DESC NULLS LAST,
|
||||
CASE WHEN sqlc.narg('sort')::text = 'last_read_asc' THEN MAX(rp.last_read_at) END ASC NULLS LAST,
|
||||
library_id, series
|
||||
LIMIT sqlc.narg('limit') OFFSET sqlc.narg('offset');
|
||||
|
||||
-- name: GetDistinctSeriesCount :one
|
||||
SELECT COUNT(DISTINCT (library_id, series))::int
|
||||
-- Count counterpart of GetDistinctSeries: identical WHERE + JOIN +
|
||||
-- HAVING so the pagination total matches what the page query returns
|
||||
-- under the same filters (Sort is a page-ORDER concern, not a set
|
||||
-- membership one, so it is absent here by design).
|
||||
SELECT COUNT(*)::int FROM (
|
||||
SELECT library_id, series
|
||||
FROM media_items
|
||||
LEFT JOIN reading_progress rp
|
||||
ON rp.media_item_id = media_items.id
|
||||
AND rp.user_id = sqlc.narg('viewer_user_id')::uuid
|
||||
WHERE (sqlc.narg('library_id')::uuid IS NULL OR library_id = sqlc.narg('library_id')::uuid)
|
||||
AND (sqlc.narg('visible_library_ids')::uuid[] IS NULL OR library_id = ANY(sqlc.narg('visible_library_ids')::uuid[]))
|
||||
AND archived_at IS NULL
|
||||
AND missing_scan_count = 0
|
||||
AND series IS NOT NULL AND series != '';
|
||||
AND series IS NOT NULL AND series != ''
|
||||
AND (sqlc.narg('series_query')::text IS NULL OR series ILIKE '%' || sqlc.narg('series_query')::text || '%')
|
||||
GROUP BY library_id, series
|
||||
HAVING (sqlc.narg('author_query')::text IS NULL OR BOOL_OR(author ILIKE '%' || sqlc.narg('author_query')::text || '%'))
|
||||
AND (
|
||||
sqlc.narg('status')::text IS NULL
|
||||
OR CASE sqlc.narg('status')::text
|
||||
WHEN 'finished' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) >= 0.999) = COUNT(*)
|
||||
WHEN 'reading' THEN COUNT(*) FILTER (WHERE rp.percentage > 0 AND rp.percentage < 0.999) >= 1
|
||||
WHEN 'unstarted' THEN COUNT(*) FILTER (WHERE COALESCE(rp.percentage, 0) > 0) = 0
|
||||
ELSE true
|
||||
END
|
||||
)
|
||||
) groups;
|
||||
|
||||
-- name: GetSeriesCovers :many
|
||||
SELECT cover_image_path, library_id
|
||||
|
||||
@@ -64,7 +64,19 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
return c.JSON(http.StatusForbidden, map[string]string{"error": "library not accessible"})
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset)
|
||||
// Sort/filter params (same contract as the SSR /series page):
|
||||
// unknown values resolve to defaults inside the service.
|
||||
opts := services.SeriesPageOptions{
|
||||
Sort: c.QueryParam("sort"),
|
||||
Query: c.QueryParam("q"),
|
||||
Author: c.QueryParam("author"),
|
||||
Status: c.QueryParam("status"),
|
||||
}
|
||||
if u, ok := c.Get("user").(database.Users); ok {
|
||||
opts.ViewerUserID = u.ID
|
||||
}
|
||||
|
||||
seriesList, total, err := h.seriesService.GetSeriesPage(c.Request().Context(), libUUID, visibleIDs, limit, offset, opts)
|
||||
if err != nil {
|
||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to load series"})
|
||||
}
|
||||
@@ -76,6 +88,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
TotalInSeries int `json:"total_in_series"`
|
||||
CoverPaths []string `json:"cover_paths"`
|
||||
LastEntryAt string `json:"last_entry_at"`
|
||||
ReadCount int64 `json:"read_count"`
|
||||
LastReadAt string `json:"last_read_at"`
|
||||
}
|
||||
|
||||
response := make([]SeriesResponse, 0, len(seriesList))
|
||||
@@ -87,6 +101,8 @@ func (h *SeriesHandler) GetSeries(c *echo.Context) error {
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: s.CoverPaths,
|
||||
LastEntryAt: s.LastEntryAt,
|
||||
ReadCount: s.ReadCount,
|
||||
LastReadAt: s.LastReadAt,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -150,7 +166,7 @@ func (h *SeriesHandler) GetSeriesBooks(c *echo.Context) error {
|
||||
})
|
||||
}
|
||||
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]services.SeriesInfo, int, error) {
|
||||
func GetSeriesCardsData(ctx context.Context, db *database.Queries, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts services.SeriesPageOptions) ([]services.SeriesInfo, int, error) {
|
||||
svc := services.NewSeriesService(db)
|
||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset)
|
||||
return svc.GetSeriesPage(ctx, libraryID, visibleLibraryIDs, limit, offset, opts)
|
||||
}
|
||||
@@ -149,6 +149,25 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
offset := (page - 1) * perSeriesPage
|
||||
|
||||
// Series-page state (URL is the source of truth — plain GET form,
|
||||
// no htmx on this page). Unknown values resolve to defaults in the
|
||||
// service's registries, so hand-edited URLs stay safe.
|
||||
state := templates.SeriesPageState{
|
||||
Sort: c.QueryParam("sort"),
|
||||
Query: c.QueryParam("q"),
|
||||
Author: c.QueryParam("author"),
|
||||
Status: c.QueryParam("status"),
|
||||
}
|
||||
opts := services.SeriesPageOptions{
|
||||
Sort: state.Sort,
|
||||
Query: state.Query,
|
||||
Author: state.Author,
|
||||
Status: state.Status,
|
||||
}
|
||||
if viewerID, vidErr := uuid.Parse(user.ID); vidErr == nil {
|
||||
opts.ViewerUserID = uuidToPGType(viewerID)
|
||||
}
|
||||
|
||||
var seriesCards []templates.SeriesCardData
|
||||
totalPages := 1
|
||||
|
||||
@@ -162,7 +181,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
} else if libRes.LibUUID.Valid && !visibleSet[libRes.LibraryID] {
|
||||
errorMsg = "Library not accessible"
|
||||
} else {
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset)
|
||||
seriesList, total, err := handlers.GetSeriesCardsData(c.Request().Context(), cfg.Queries, libRes.LibUUID, visibleIDs, perSeriesPage, offset, opts)
|
||||
if err != nil {
|
||||
log.Printf("GetSeriesCardsData failed: %v", err)
|
||||
errorMsg = "Error loading series"
|
||||
@@ -183,6 +202,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
BookCount: s.BookCount,
|
||||
TotalInSeries: s.TotalInSeries,
|
||||
CoverPaths: covers,
|
||||
ReadCount: s.ReadCount,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -194,7 +214,7 @@ func registerFrontendRoutes(cfg *Config) {
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, errorMsg).Render(c.Request().Context(), &buf)
|
||||
err = templates.Series(user, seriesCards, libData, libraryID, totalPages, page, state, errorMsg).Render(c.Request().Context(), &buf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -16,6 +16,69 @@ type SeriesInfo struct {
|
||||
TotalInSeries int
|
||||
CoverPaths []string
|
||||
LastEntryAt string
|
||||
// Viewer-scoped progress aggregates (requesting user): books
|
||||
// finished (percentage >= 0.999) and the series' most recent read.
|
||||
ReadCount int64
|
||||
LastReadAt string
|
||||
}
|
||||
|
||||
// SeriesPageOptions carries the series page's sort/filter state. Zero
|
||||
// values mean defaults (recent, no filters). Extensible by design:
|
||||
// adding a sort = one entry in seriesSortKeys + a matching CASE arm in
|
||||
// queries.sql (GetDistinctSeries) + one <option> in templates/
|
||||
// series.templ; adding a filter = a param on this struct threaded into
|
||||
// the query's WHERE/HAVING. Unknown values resolve to defaults, so a
|
||||
// stale URL or hand-rolled API call can never reach the SQL with an
|
||||
// unhandled key.
|
||||
type SeriesPageOptions struct {
|
||||
Sort string // registry key, resolved by ResolveSeriesSort
|
||||
Query string // series-name substring
|
||||
Author string // matches when ANY book's author matches
|
||||
Status string // "" | unstarted | reading | finished
|
||||
ViewerUserID pgtype.UUID
|
||||
}
|
||||
|
||||
// seriesSortKeys is the registry of sort= values the series page
|
||||
// understands.
|
||||
var seriesSortKeys = map[string]bool{
|
||||
"recent": true, // default: newest series first
|
||||
"recent_asc": true,
|
||||
"name_asc": true,
|
||||
"name_desc": true,
|
||||
"author_asc": true,
|
||||
"author_desc": true,
|
||||
"books_asc": true,
|
||||
"books_desc": true,
|
||||
"progress_asc": true,
|
||||
"progress_desc": true,
|
||||
"last_read_asc": true,
|
||||
"last_read_desc": true,
|
||||
}
|
||||
|
||||
// seriesStatusKeys is the registry of status= values. The three states
|
||||
// are mutually exclusive and exhaustive: a series is finished when
|
||||
// every owned book is, reading when any book sits between 0 and the
|
||||
// finish threshold, unstarted when no book has progress > 0.
|
||||
var seriesStatusKeys = map[string]bool{
|
||||
"unstarted": true,
|
||||
"reading": true,
|
||||
"finished": true,
|
||||
}
|
||||
|
||||
// ResolveSeriesSort maps any unknown/empty sort to the default. It is
|
||||
// the single source of truth backing both the SSR page and the JSON
|
||||
// API — the SQL CASE cascade must contain an arm for every key here.
|
||||
func ResolveSeriesSort(sort string) string {
|
||||
if seriesSortKeys[sort] {
|
||||
return sort
|
||||
}
|
||||
return "recent"
|
||||
}
|
||||
|
||||
// nargText makes an optional text param: empty string → SQL NULL so
|
||||
// the query's IS NULL guard skips the filter.
|
||||
func nargText(v string) pgtype.Text {
|
||||
return pgtype.Text{String: v, Valid: v != ""}
|
||||
}
|
||||
|
||||
type SeriesService struct {
|
||||
@@ -31,18 +94,34 @@ func NewSeriesService(db *database.Queries) *SeriesService {
|
||||
// Pass libraryID for a single-library page, or leave it invalid and pass
|
||||
// visibleLibraryIDs for an across-my-libraries page. Both filters compose:
|
||||
// the handler validates any explicit library_id against the visible set.
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int) ([]SeriesInfo, int, error) {
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, database.GetDistinctSeriesCountParams{
|
||||
func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID, visibleLibraryIDs []pgtype.UUID, limit, offset int, opts SeriesPageOptions) ([]SeriesInfo, int, error) {
|
||||
sort := nargText(ResolveSeriesSort(opts.Sort))
|
||||
status := nargText("")
|
||||
if seriesStatusKeys[opts.Status] {
|
||||
status = nargText(opts.Status)
|
||||
}
|
||||
|
||||
countParams := database.GetDistinctSeriesCountParams{
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
})
|
||||
SeriesQuery: nargText(opts.Query),
|
||||
AuthorQuery: nargText(opts.Author),
|
||||
Status: status,
|
||||
}
|
||||
totalCount, err := s.db.GetDistinctSeriesCount(ctx, countParams)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
rows, err := s.db.GetDistinctSeries(ctx, database.GetDistinctSeriesParams{
|
||||
ViewerUserID: opts.ViewerUserID,
|
||||
LibraryID: libraryID,
|
||||
VisibleLibraryIds: visibleLibraryIDs,
|
||||
SeriesQuery: countParams.SeriesQuery,
|
||||
AuthorQuery: countParams.AuthorQuery,
|
||||
Status: status,
|
||||
Sort: sort,
|
||||
Limit: pgtype.Int4{Int32: int32(limit), Valid: true},
|
||||
Offset: pgtype.Int4{Int32: int32(offset), Valid: true},
|
||||
})
|
||||
@@ -76,6 +155,18 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
}
|
||||
}
|
||||
|
||||
lastRead := ""
|
||||
if row.LastReadAt != nil {
|
||||
switch v := row.LastReadAt.(type) {
|
||||
case pgtype.Timestamptz:
|
||||
if v.Valid {
|
||||
lastRead = v.Time.String()
|
||||
}
|
||||
case string:
|
||||
lastRead = v
|
||||
}
|
||||
}
|
||||
|
||||
if totalInSeries == 0 || totalInSeries < int(row.BookCount) {
|
||||
totalInSeries = int(row.BookCount)
|
||||
}
|
||||
@@ -87,6 +178,8 @@ func (s *SeriesService) GetSeriesPage(ctx context.Context, libraryID pgtype.UUID
|
||||
TotalInSeries: totalInSeries,
|
||||
CoverPaths: coverPaths,
|
||||
LastEntryAt: lastEntry,
|
||||
ReadCount: row.FinishedCount,
|
||||
LastReadAt: lastRead,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+160
-3
@@ -3,9 +3,73 @@ package templates
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) {
|
||||
// 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 SQL arm and registry entry.
|
||||
var seriesSortOptions = []struct {
|
||||
Value string
|
||||
Label string
|
||||
}{
|
||||
{"recent", "Recently added"},
|
||||
{"recent_asc", "Oldest added"},
|
||||
{"name_asc", "Name (A–Z)"},
|
||||
{"name_desc", "Name (Z–A)"},
|
||||
{"author_asc", "Author (A–Z)"},
|
||||
{"author_desc", "Author (Z–A)"},
|
||||
{"books_desc", "Most books"},
|
||||
{"books_asc", "Fewest books"},
|
||||
{"progress_desc", "Most complete"},
|
||||
{"progress_asc", "Least complete"},
|
||||
{"last_read_desc", "Recently read"},
|
||||
{"last_read_asc", "Least recently 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.Query != "" {
|
||||
vals.Set("q", state.Query)
|
||||
}
|
||||
if state.Author != "" {
|
||||
vals.Set("author", state.Author)
|
||||
}
|
||||
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.Author != "" || 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>
|
||||
@@ -29,7 +93,72 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
<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…"
|
||||
class="input w-full pl-9"
|
||||
aria-label="Search series by name"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
name="author"
|
||||
value={ state.Author }
|
||||
placeholder="Author"
|
||||
class="input w-40"
|
||||
aria-label="Filter series by author"
|
||||
/>
|
||||
<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"
|
||||
@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>
|
||||
<button type="submit" class="btn btn-secondary" aria-label="Apply filters">
|
||||
@Icon("filter", "h-4 w-4")
|
||||
<span>Apply</span>
|
||||
</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")
|
||||
@@ -38,6 +167,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
<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)
|
||||
@@ -47,7 +177,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
<div id="series-pagination" class="flex justify-center items-center gap-3 mt-10">
|
||||
if currentPage > 1 {
|
||||
<a
|
||||
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1) }
|
||||
href={ seriesPageURL(state, currentLibraryID, currentPage-1) }
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
@Icon("chevron-left", "h-4 w-4")
|
||||
@@ -59,7 +189,7 @@ templ Series(user User, seriesList []SeriesCardData, libData []LibraryData, curr
|
||||
</span>
|
||||
if currentPage < totalPages {
|
||||
<a
|
||||
href={ fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1) }
|
||||
href={ seriesPageURL(state, currentLibraryID, currentPage+1) }
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
<span>Next</span>
|
||||
@@ -99,7 +229,34 @@ templ SeriesCard(series SeriesCardData) {
|
||||
<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
|
||||
}
|
||||
+377
-86
@@ -11,9 +11,73 @@ import templruntime "github.com/a-h/templ/runtime"
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, errorMessage string) templ.Component {
|
||||
// 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 SQL arm and registry entry.
|
||||
var seriesSortOptions = []struct {
|
||||
Value string
|
||||
Label string
|
||||
}{
|
||||
{"recent", "Recently added"},
|
||||
{"recent_asc", "Oldest added"},
|
||||
{"name_asc", "Name (A–Z)"},
|
||||
{"name_desc", "Name (Z–A)"},
|
||||
{"author_asc", "Author (A–Z)"},
|
||||
{"author_desc", "Author (Z–A)"},
|
||||
{"books_desc", "Most books"},
|
||||
{"books_asc", "Fewest books"},
|
||||
{"progress_desc", "Most complete"},
|
||||
{"progress_asc", "Least complete"},
|
||||
{"last_read_desc", "Recently read"},
|
||||
{"last_read_asc", "Least recently 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.Query != "" {
|
||||
vals.Set("q", state.Query)
|
||||
}
|
||||
if state.Author != "" {
|
||||
vals.Set("author", state.Author)
|
||||
}
|
||||
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.Author != "" || state.Status != "" || (state.Sort != "" && state.Sort != "recent")
|
||||
}
|
||||
|
||||
func Series(user User, seriesList []SeriesCardData, libData []LibraryData, currentLibraryID string, totalPages int, currentPage int, state SeriesPageState, errorMessage string) templ.Component {
|
||||
return templruntime.GeneratedTemplate(func(templ_7745c5c3_Input templruntime.GeneratedComponentInput) (templ_7745c5c3_Err error) {
|
||||
templ_7745c5c3_W, ctx := templ_7745c5c3_Input.Writer, templ_7745c5c3_Input.Context
|
||||
if templ_7745c5c3_CtxErr := ctx.Err(); templ_7745c5c3_CtxErr != nil {
|
||||
@@ -76,12 +140,186 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 string
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.ResolveAttributeValue(currentLibraryID)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 102, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var4)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "\"><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)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "</span> <input type=\"text\" name=\"q\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Query)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 110, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var5)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "\" placeholder=\"Search series…\" class=\"input w-full pl-9\" aria-label=\"Search series by name\"></div><input type=\"text\" name=\"author\" value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.ResolveAttributeValue(state.Author)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 119, Col: 27}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var6)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\" placeholder=\"Author\" class=\"input w-40\" aria-label=\"Filter series by author\"> <select name=\"status\" class=\"input w-auto cursor-pointer\" aria-label=\"Filter series by your progress\" @change=\"$el.form.requestSubmit()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range seriesStatusOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 131, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var7)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if state.Status == opt.Value {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 131, Col: 87}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</select> <select name=\"sort\" class=\"input w-auto cursor-pointer\" aria-label=\"Sort series\" @change=\"$el.form.requestSubmit()\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for _, opt := range seriesSortOptions {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<option value=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.ResolveAttributeValue(opt.Value)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 141, Col: 33}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var9)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if (state.Sort == "" && opt.Value == "recent") || state.Sort == opt.Value {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, " selected")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, ">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(opt.Label)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 141, Col: 132}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</option>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</select> <button type=\"submit\" class=\"btn btn-secondary\" aria-label=\"Apply filters\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("filter", "h-4 w-4").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<span>Apply</span></button></form>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if len(seriesList) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 6, "<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);\">")
|
||||
if hasActiveSeriesFilters(state) {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = Icon("search", "h-7 w-7").Render(ctx, templ_7745c5c3_Buffer)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</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=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 templ.SafeURL
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(SeriesPageState{}, currentLibraryID, 1))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 157, Col: 71}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" class=\"btn btn-secondary\">Clear filters</a></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
} else {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<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);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -89,12 +327,13 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 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>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</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>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<div id=\"series-grid\" class=\"grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-6\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -104,30 +343,30 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if totalPages > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<div id=\"series-pagination\" class=\"flex justify-center items-center gap-3 mt-10\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentPage > 1 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var4 templ.SafeURL
|
||||
templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage-1))
|
||||
var templ_7745c5c3_Var12 templ.SafeURL
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(state, currentLibraryID, currentPage-1))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 50, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 180, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "\" class=\"btn btn-secondary\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" class=\"btn btn-secondary\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -135,56 +374,56 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<span>Previous</span></a> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "<span>Previous</span></a> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<span class=\"text-sm\" style=\"color: var(--text-secondary)\">Page ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var5 string
|
||||
templ_7745c5c3_Var5, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", currentPage))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 45}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 188, Col: 45}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var5))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " of ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " of ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", totalPages))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 58, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 188, Col: 82}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</span> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</span> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if currentPage < totalPages {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var7 templ.SafeURL
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinURLErrs(fmt.Sprintf("/series?library_id=%s&page=%d", currentLibraryID, currentPage+1))
|
||||
var templ_7745c5c3_Var15 templ.SafeURL
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinURLErrs(seriesPageURL(state, currentLibraryID, currentPage+1))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 62, Col: 93}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 192, Col: 69}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" class=\"btn btn-secondary\"><span>Next</span>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" class=\"btn btn-secondary\"><span>Next</span>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -192,17 +431,17 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</main></div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</main></div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -210,7 +449,7 @@ func Series(user User, seriesList []SeriesCardData, libData []LibraryData, curre
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</body></html>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</body></html>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -234,102 +473,102 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
}()
|
||||
}
|
||||
ctx = templ.InitializeContext(ctx)
|
||||
templ_7745c5c3_Var8 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var8 == nil {
|
||||
templ_7745c5c3_Var8 = templ.NopComponent
|
||||
templ_7745c5c3_Var16 := templ.GetChildren(ctx)
|
||||
if templ_7745c5c3_Var16 == nil {
|
||||
templ_7745c5c3_Var16 = templ.NopComponent
|
||||
}
|
||||
ctx = templ.ClearChildren(ctx)
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<a href=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<a href=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var9 templ.SafeURL
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID))
|
||||
var templ_7745c5c3_Var17 templ.SafeURL
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinURLErrs("/series/detail?name=" + url.QueryEscape(series.Name) + "&library_id=" + url.QueryEscape(series.LibraryID))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 79, Col: 117}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 209, Col: 117}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" class=\"block\"><div class=\"series-card card rounded-2xl overflow-hidden cursor-pointer\" style=\"box-shadow: var(--shadow-card);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var10 = []any{"stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths))}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var10...)
|
||||
var templ_7745c5c3_Var18 = []any{"stacked-covers cover-count-" + fmt.Sprintf("%d", len(series.CoverPaths))}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var18...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<div class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "<div class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var10).String())
|
||||
var templ_7745c5c3_Var19 string
|
||||
templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var18).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var11)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
for i, cover := range series.CoverPaths {
|
||||
var templ_7745c5c3_Var12 = []any{"cover-img cover-" + fmt.Sprintf("%d", i)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var12...)
|
||||
var templ_7745c5c3_Var20 = []any{"cover-img cover-" + fmt.Sprintf("%d", i)}
|
||||
templ_7745c5c3_Err = templ.RenderCSSItems(ctx, templ_7745c5c3_Buffer, templ_7745c5c3_Var20...)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<img src=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "<img src=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var13 string
|
||||
templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
|
||||
var templ_7745c5c3_Var21 string
|
||||
templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(cover)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 84, Col: 17}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 214, Col: 17}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var13)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" class=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" class=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var12).String())
|
||||
var templ_7745c5c3_Var22 string
|
||||
templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(templ.CSSClasses(templ_7745c5c3_Var20).String())
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 1, Col: 0}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" alt=\"")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" alt=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var15 string
|
||||
templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
|
||||
var templ_7745c5c3_Var23 string
|
||||
templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(series.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 86, Col: 23}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 216, Col: 23}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" loading=\"lazy\"> ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\" loading=\"lazy\"> ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
if len(series.CoverPaths) == 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "<div class=\"cover-img cover-0 flex items-center justify-center\" style=\"background: var(--bg-primary); color: var(--text-secondary);\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -337,51 +576,87 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</div>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "</div>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "</div><div class=\"series-card-info px-3 py-2\"><h3 class=\"font-semibold text-sm line-clamp-2\" style=\"color: var(--text-primary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var16 string
|
||||
templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
|
||||
var templ_7745c5c3_Var24 string
|
||||
templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(series.Name)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 98, Col: 99}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 228, Col: 99}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var16))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</h3><p class=\"text-xs mt-1\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var17 string
|
||||
templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
|
||||
var templ_7745c5c3_Var25 string
|
||||
templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.BookCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 42}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 42}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var17))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, " of ")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " of ")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var18 string
|
||||
templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
|
||||
var templ_7745c5c3_Var26 string
|
||||
templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d", series.TotalInSeries))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 100, Col: 89}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 230, Col: 89}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, " books</p></div></div></a>")
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " books</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
if series.ReadCount > 0 {
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, "<div class=\"mt-1.5 h-1 rounded-full overflow-hidden\" style=\"background: var(--bg-primary);\"><div class=\"h-full rounded-full\" style=\"")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var27 string
|
||||
templ_7745c5c3_Var27, templ_7745c5c3_Err = templruntime.SanitizeStyleAttributeValues(fmt.Sprintf("width: %d%%; background-color: var(--accent);", readPercent(series.ReadCount, series.BookCount)))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 236, Col: 124}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var27))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, "\"></div></div><p class=\"text-[10px] mt-0.5\" style=\"color: var(--text-secondary)\">")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
var templ_7745c5c3_Var28 string
|
||||
templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%d of %d read", series.ReadCount, series.BookCount))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `templates/series.templ`, Line: 240, Col: 72}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var28))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, "</p>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, "</div></div></a>")
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -389,4 +664,20 @@ func SeriesCard(series SeriesCardData) templ.Component {
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var _ = templruntime.GeneratedTemplate
|
||||
@@ -142,6 +142,19 @@ type SeriesCardData struct {
|
||||
BookCount int64
|
||||
TotalInSeries int
|
||||
CoverPaths []string
|
||||
// Viewer's finished books in this series (drives the card's
|
||||
// progress bar / "X of N read" line).
|
||||
ReadCount int64
|
||||
}
|
||||
|
||||
// SeriesPageState is the /series page's sort/filter state, echoed back
|
||||
// into the toolbar controls and the pagination URLs (the URL is the
|
||||
// single source of truth on this plain-GET page).
|
||||
type SeriesPageState struct {
|
||||
Sort string
|
||||
Query string
|
||||
Author string
|
||||
Status string
|
||||
}
|
||||
|
||||
type PendingRegistrationData struct {
|
||||
|
||||
+16
-9
@@ -1,16 +1,23 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { initLibrarySwitcher } from "./library-switcher";
|
||||
|
||||
// Rendering ownership (AGENTS.md): the series grid and its paginator are
|
||||
// rendered by templates/series.templ. This page intentionally has NO
|
||||
// TypeScript renderer — the old one here duplicated the templ markup via
|
||||
// innerHTML and re-fetched at hardcoded page 1, stomping SSR pagination
|
||||
// whenever the header's library restore fired. A library switch is a plain
|
||||
// SSR navigation so the URL stays the single source of truth for
|
||||
// library_id and page state.
|
||||
// Rendering ownership (AGENTS.md): the series grid, its paginator and
|
||||
// its sort/filter toolbar are rendered by templates/series.templ. This
|
||||
// page intentionally has NO TypeScript renderer — the old one here
|
||||
// duplicated the templ markup via innerHTML and re-fetched at hardcoded
|
||||
// page 1, stomping SSR pagination whenever the header's library restore
|
||||
// fired. A library switch is a plain SSR navigation so the URL stays
|
||||
// the single source of truth for library_id, page AND sort/filter state
|
||||
// (the switch carries the current q/author/status/sort params along).
|
||||
function navigateToSeries(libraryId: string): void {
|
||||
const param = libraryId ? `library_id=${encodeURIComponent(libraryId)}&` : "";
|
||||
window.location.href = `/series?${param}page=1`;
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (libraryId) {
|
||||
params.set("library_id", libraryId);
|
||||
} else {
|
||||
params.delete("library_id");
|
||||
}
|
||||
params.set("page", "1");
|
||||
window.location.href = `/series?${params.toString()}`;
|
||||
}
|
||||
|
||||
Alpine.data("seriesPage", () => ({
|
||||
|
||||
Reference in new issue
Block a user