From 4d96ec8b95711e2eb7533e48eac7b40f9c41505b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 12:46:43 +1100 Subject: [PATCH 01/13] Add paging feature design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-22-paging-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-paging-design.md diff --git a/docs/superpowers/specs/2026-03-22-paging-design.md b/docs/superpowers/specs/2026-03-22-paging-design.md new file mode 100644 index 0000000..80e3ee6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-paging-design.md @@ -0,0 +1,100 @@ +# Paging Feature Design + +## Overview + +Introduce offset-based pagination to the admin post list and the generated static site (both post listings and category listings). + +## Data Layer + +### New `sites` column + +Add `posts_per_page INTEGER NOT NULL DEFAULT 10` to the `sites` table. This setting controls the number of posts per page on the **generated static site only**. + +### New SQL queries + +- `CountPostsOfSite(siteID, showDeleted)` — returns total post count for the site +- `CountPostsOfCategory(categoryID)` — returns total published post count for a category + +### Model changes + +**`models.Site`** — add field: +```go +PostsPerPage int +``` + +**New shared type** (`models/paging.go`): +```go +type PageInfo struct { + CurrentPage int + TotalPages int + PostsPerPage int +} +``` + +Existing `db.PagingParams` and queries (`SelectPostsOfSite`, `SelectPostsOfCategory`) already support `LIMIT/OFFSET` and remain unchanged. + +## Admin Section + +### Post list pagination + +- **Page size: hardcoded at 25** (not tied to the `PostsPerPage` site setting) +- Handler (`handlers/posts.go` `Index()`) reads a `page` query parameter (default 1) +- Computes offset as `(page - 1) * 25` +- Fetches total post count via new `CountPosts()` service method to build `PageInfo` +- Passes `PageInfo` to template + +### Service changes + +- `ListPosts()` accepts paging params from the handler instead of hardcoding them +- New `CountPosts()` method that calls the count query + +### Template (`views/posts/index.html`) + +- Full numbered pagination with Previous/Next below the post list: `< 1 2 3 ... 10 >` +- Preserves existing query params (e.g. `?filter=deleted`) when paginating +- Both regular post list and trash view are paginated + +### Site settings form + +- Add "Posts per page" number input to `views/sitesettings/general.html` +- Add `PostsPerPage` field to `UpdateSiteSettingsParams` +- Server-side validation: minimum 1, maximum 100 + +## Generated Static Site + +### URL structure + +Post listing pages: +- `/posts/` — page 1 +- `/posts/page/2/` — page 2 +- `/posts/page/N/` — page N + +Category listing pages: +- `/categories//` — page 1 +- `/categories//page/2/` — page 2 +- `/categories//page/N/` — page N + +### Site root + +`/` (site root) shows the same content as `/posts/` (page 1 of all posts). + +### Builder changes (`providers/sitebuilder/builder.go`) + +- Instead of rendering one `posts_list.html` with all posts, generate multiple page files +- Uses `site.PostsPerPage` from the site setting to determine page size +- Same pattern for category pages + +### Publisher changes (`services/publisher/iter.go`) + +- Existing iterator fetches posts in batches of 50 internally — this stays as-is +- The builder chunks posts into pages of `PostsPerPage` size and renders each page as a separate HTML file + +### Template (`layouts/simplecss/templates/posts_list.html`) + +- Receives `PageInfo` plus the posts for that page +- Renders **Previous / Next** links only (no numbered pagination) +- Previous link hidden on page 1; Next link hidden on last page + +## Approach + +Offset-based pagination using the existing `db.PagingParams` infrastructure. Page number maps to offset: `offset = (page - 1) * postsPerPage`. -- 2.43.0 From 7c4dc0885e444f7ce95571dd572765e90f9bf87d Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 13:12:28 +1100 Subject: [PATCH 02/13] Add paging implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/plans/2026-03-22-paging.md | 888 ++++++++++++++++++++ 1 file changed, 888 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-paging.md diff --git a/docs/superpowers/plans/2026-03-22-paging.md b/docs/superpowers/plans/2026-03-22-paging.md new file mode 100644 index 0000000..9b44775 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-paging.md @@ -0,0 +1,888 @@ +# Paging Feature Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add offset-based pagination to the admin post list and the generated static site (posts and category listings). + +**Architecture:** Add a `posts_per_page` column to the `sites` table for configurable page size on the generated site. Admin uses a hardcoded page size of 25. The existing `db.PagingParams` and `LIMIT/OFFSET` SQL infrastructure is reused. A shared `models.PageInfo` type carries pagination state to templates. + +**Tech Stack:** Go, SQLite, sqlc, Fiber v3, html/template, Bootstrap + +--- + +### Task 1: Add `posts_per_page` column and regenerate sqlc + +**Files:** +- Create: `sql/schema/05_posts_per_page.up.sql` +- Modify: `sql/queries/sites.sql:10-19` (InsertSite query) +- Modify: `sql/queries/sites.sql:24-25` (UpdateSite query) +- Regenerate: `providers/db/gen/sqlgen/` (sqlc output) + +- [ ] **Step 1: Create migration file** + +Create `sql/schema/05_posts_per_page.up.sql`: +```sql +ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; +``` + +- [ ] **Step 2: Update the InsertSite SQL query** + +In `sql/queries/sites.sql`, update the InsertSite query (lines 10-19) to include `posts_per_page`: +```sql +-- name: InsertSite :one +INSERT INTO sites ( + owner_id, + guid, + title, + tagline, + timezone, + posts_per_page, + created_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id; +``` + +- [ ] **Step 3: Update the UpdateSite SQL query** + +In `sql/queries/sites.sql`, update line 24-25: +```sql +-- name: UpdateSite :exec +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; +``` + +- [ ] **Step 4: Regenerate sqlc** + +Run: `sqlc generate` +Expected: `providers/db/gen/sqlgen/` files updated with new `PostsPerPage` field on `Site` struct, updated `InsertSiteParams` and `UpdateSiteParams`. + +- [ ] **Step 5: Run tests to verify nothing broke** + +Run: `go test ./...` +Expected: All existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add sql/schema/05_posts_per_page.up.sql sql/queries/sites.sql providers/db/gen/sqlgen/ +git commit -m "feat: add posts_per_page column to sites table" +``` + +--- + +### Task 2: Update Site model and DB provider for `PostsPerPage` + +**Files:** +- Modify: `models/sites.go:24-33` (Site struct) +- Modify: `providers/db/sites.go:42-65` (SaveSite) +- Modify: `providers/db/sites.go:102-112` (dbSiteToSite) + +- [ ] **Step 1: Add `PostsPerPage` to `models.Site`** + +In `models/sites.go`, add to the `Site` struct (after `Timezone`): +```go +PostsPerPage int +``` + +- [ ] **Step 2: Update `dbSiteToSite` in `providers/db/sites.go`** + +In `providers/db/sites.go`, update `dbSiteToSite` (line 102) to map the new field: +```go +func dbSiteToSite(row sqlgen.Site) models.Site { + return models.Site{ + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Timezone: row.Timezone, + Tagline: row.Tagline, + PostsPerPage: int(row.PostsPerPage), + Created: time.Unix(row.CreatedAt, 0).UTC(), + } +} +``` + +- [ ] **Step 3: Update `SaveSite` to include `PostsPerPage`** + +In `providers/db/sites.go`, update the `InsertSite` call (line 44) to include `PostsPerPage`: +```go +newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ + OwnerID: site.OwnerID, + Guid: site.GUID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + CreatedAt: timeToInt(site.Created), +}) +``` + +Update the `UpdateSite` call (line 59) to include `PostsPerPage`: +```go +return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + ID: site.ID, +}) +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add models/sites.go providers/db/sites.go sql/queries/sites.sql providers/db/gen/sqlgen/ +git commit -m "feat: add PostsPerPage to Site model and DB provider" +``` + +--- + +### Task 3: Add `CountPostsOfSite` SQL query and DB method + +**Files:** +- Modify: `sql/queries/posts.sql` (add count query) +- Modify: `providers/db/posts.go` (add CountPostsOfSite method) +- Modify: `providers/db/provider_test.go` (add test) +- Regenerate: `providers/db/gen/sqlgen/` + +- [ ] **Step 1: Write the failing test** + +Add to `providers/db/provider_test.go` inside `TestProvider_Posts`: +```go +t.Run("count posts of site", func(t *testing.T) { + countSite := &models.Site{ + OwnerID: user.ID, + GUID: models.NewNanoID(), + Title: "Count Blog", + } + require.NoError(t, p.SaveSite(ctx, countSite)) + + now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC) + for i := 0; i < 3; i++ { + post := &models.Post{ + SiteID: countSite.ID, + GUID: models.NewNanoID(), + Title: fmt.Sprintf("Post %d", i), + Body: "body", + Slug: fmt.Sprintf("/post-%d", i), + CreatedAt: now, + } + require.NoError(t, p.SavePost(ctx, post)) + } + + count, err := p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(3), count) + + // Soft-delete one post + posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0}) + require.NoError(t, err) + require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID)) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` +Expected: FAIL — `CountPostsOfSite` method does not exist. + +- [ ] **Step 3: Add SQL query** + +Add to `sql/queries/posts.sql`: +```sql +-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = sqlc.arg(site_id) AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +); +``` + +Run: `sqlc generate` + +- [ ] **Step 4: Add DB provider method** + +Add to `providers/db/posts.go`: +```go +func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) { + filter := "active" + if showDeleted { + filter = "deleted" + } + return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{ + SiteID: siteID, + PostFilter: filter, + }) +} +``` + +Note: check the generated `sqlgen.CountPostsOfSiteParams` struct name and fields after `sqlc generate` — adjust if the field names differ. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` +Expected: PASS + +- [ ] **Step 6: Run all tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add sql/queries/posts.sql providers/db/posts.go providers/db/provider_test.go providers/db/gen/sqlgen/ +git commit -m "feat: add CountPostsOfSite query and DB method" +``` + +--- + +### Task 4: Add `models.PageInfo` type + +**Files:** +- Create: `models/paging.go` + +- [ ] **Step 1: Create `models/paging.go`** + +```go +package models + +// PageInfo carries pagination state for templates. +type PageInfo struct { + CurrentPage int + TotalPages int + PostsPerPage int +} + +// HasPrevious returns true if there is a previous page. +func (p PageInfo) HasPrevious() bool { + return p.CurrentPage > 1 +} + +// HasNext returns true if there is a next page. +func (p PageInfo) HasNext() bool { + return p.CurrentPage < p.TotalPages +} + +// PreviousPage returns the previous page number. +func (p PageInfo) PreviousPage() int { + return p.CurrentPage - 1 +} + +// NextPage returns the next page number. +func (p PageInfo) NextPage() int { + return p.CurrentPage + 1 +} +``` + +- [ ] **Step 2: Run tests** + +Run: `go test ./...` +Expected: All pass (no tests yet for this type, but it should compile). + +- [ ] **Step 3: Commit** + +```bash +git add models/paging.go +git commit -m "feat: add PageInfo model for pagination" +``` + +--- + +### Task 5: Add pagination to admin post list (service + handler) + +**Files:** +- Modify: `services/posts/list.go:15-38` (ListPosts signature and implementation) +- Modify: `handlers/posts.go:18-39` (Index handler) + +- [ ] **Step 1: Update `ListPosts` to accept paging params and return count** + +Replace `services/posts/list.go` `ListPosts` method: +```go +type ListPostsResult struct { + Posts []*PostWithCategories + TotalCount int64 +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { + site, ok := models.GetSite(ctx) + if !ok { + return ListPostsResult{}, models.SiteRequiredError + } + + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) + if err != nil { + return ListPostsResult{}, err + } + + count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) + if err != nil { + return ListPostsResult{}, err + } + + result := make([]*PostWithCategories, len(posts)) + for i, post := range posts { + cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) + if err != nil { + return ListPostsResult{}, err + } + result[i] = &PostWithCategories{Post: post, Categories: cats} + } + return ListPostsResult{Posts: result, TotalCount: count}, nil +} +``` + +- [ ] **Step 2: Update the admin handler** + +Replace `handlers/posts.go` `Index` method: +```go +func (ph PostsHandler) Index(c fiber.Ctx) error { + var req struct { + Filter string `query:"filter"` + Page int `query:"page"` + } + if err := c.Bind().Query(&req); err != nil { + return fiber.ErrBadRequest + } + + const perPage = 25 + if req.Page < 1 { + req.Page = 1 + } + + result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{ + Offset: int64((req.Page - 1) * perPage), + Limit: perPage, + }) + if err != nil { + return err + } + + totalPages := int(result.TotalCount+int64(perPage)-1) / perPage + if totalPages < 1 { + totalPages = 1 + } + + pageInfo := models.PageInfo{ + CurrentPage: req.Page, + TotalPages: totalPages, + PostsPerPage: perPage, + } + + return accepts(c, json(func() any { + return result.Posts + }), html(func(c fiber.Ctx) error { + return c.Render("posts/index", fiber.Map{ + "req": req, + "posts": result.Posts, + "pageInfo": pageInfo, + }) + })) +} +``` + +Note: add `"lmika.dev/lmika/weiro/providers/db"` and `"lmika.dev/lmika/weiro/models"` to imports in `handlers/posts.go`. + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./...` +Expected: Compiles successfully. + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add services/posts/list.go handlers/posts.go +git commit -m "feat: add pagination to admin post list handler and service" +``` + +--- + +### Task 6: Add pagination UI to admin post list template + +**Files:** +- Modify: `views/posts/index.html` + +- [ ] **Step 1: Add pagination controls to admin template** + +Add pagination controls after the post list in `views/posts/index.html`. Insert before the closing `` tag: + +```html +{{ if gt .pageInfo.TotalPages 1 }} + +{{ end }} +``` + +- [ ] **Step 2: Add `Pages` method to `PageInfo`** + +Add to `models/paging.go`: +```go +// Pages returns a slice of page numbers for rendering numbered pagination. +func (p PageInfo) Pages() []int { + pages := make([]int, p.TotalPages) + for i := range pages { + pages[i] = i + 1 + } + return pages +} +``` + +- [ ] **Step 3: Verify it compiles and test manually** + +Run: `go build ./...` +Expected: Compiles. + +- [ ] **Step 4: Commit** + +```bash +git add views/posts/index.html models/paging.go +git commit -m "feat: add pagination controls to admin post list" +``` + +--- + +### Task 7: Add site settings form for `PostsPerPage` + +**Files:** +- Modify: `views/sitesettings/general.html:17-48` (form) +- Modify: `services/sites/services.go:131-158` (UpdateSiteSettingsParams and UpdateSiteSettings) + +- [ ] **Step 1: Add `PostsPerPage` to `UpdateSiteSettingsParams`** + +In `services/sites/services.go`, update the struct (line 131): +```go +type UpdateSiteSettingsParams struct { + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` + PostsPerPage int `form:"postsPerPage"` +} +``` + +- [ ] **Step 2: Update `UpdateSiteSettings` to handle `PostsPerPage`** + +In `services/sites/services.go`, update `UpdateSiteSettings` (line 138) to validate and set the new field: +```go +func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { + site, err := s.GetSiteByID(ctx, params.SiteID) + if err != nil { + return models.Site{}, err + } + + _, err = time.LoadLocation(params.Timezone) + if err != nil { + return models.Site{}, errors.Wrap(err, "invalid timezone") + } + + postsPerPage := params.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 1 + } else if postsPerPage > 100 { + postsPerPage = 100 + } + + site.Title = params.Name + site.Tagline = params.Tagline + site.Timezone = params.Timezone + site.PostsPerPage = postsPerPage + + if err := s.db.SaveSite(ctx, &site); err != nil { + return models.Site{}, err + } + + return site, nil +} +``` + +- [ ] **Step 3: Add form field to settings template** + +In `views/sitesettings/general.html`, add after the Timezone field (after line 43, before the submit button row): +```html +
+ +
+ +
Number of posts per page on the generated site.
+
+
+``` + +- [ ] **Step 4: Verify it compiles** + +Run: `go build ./...` +Expected: Compiles. + +- [ ] **Step 5: Commit** + +```bash +git add services/sites/services.go views/sitesettings/general.html +git commit -m "feat: add posts per page setting to site settings" +``` + +--- + +### Task 8: Add pagination to generated site post list + +**Files:** +- Modify: `providers/sitebuilder/tmpls.go:62-65` (postListData) +- Modify: `providers/sitebuilder/builder.go:124-146` (renderPostListWithCategories) +- Modify: `layouts/simplecss/templates/posts_list.html` + +- [ ] **Step 1: Update `postListData` to include `PageInfo`** + +In `providers/sitebuilder/tmpls.go`, update `postListData` (line 62): +```go +type postListData struct { + commonData + Posts []postSingleData + PageInfo models.PageInfo + PrevURL string + NextURL string +} +``` + +- [ ] **Step 2: Rewrite `renderPostListWithCategories` to paginate** + +Replace `renderPostListWithCategories` in `providers/sitebuilder/builder.go` (line 124): +```go +func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { + // Collect all posts + var allPosts []postSingleData + for mp := range b.site.PostIter(ctx) { + post, err := mp.Get() + if err != nil { + return err + } + rp, err := b.renderPostWithCategories(ctx, post) + if err != nil { + return err + } + allPosts = append(allPosts, rp) + } + + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 + } + + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 + } + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = "/posts/" + } else { + prevURL = fmt.Sprintf("/posts/page/%d/", page-1) + } + } + if page < totalPages { + nextURL = fmt.Sprintf("/posts/page/%d/", page+1) + } + + pl := postListData{ + commonData: commonData{Site: b.site}, + Posts: allPosts[start:end], + PageInfo: pageInfo, + PrevURL: prevURL, + NextURL: nextURL, + } + + // Determine output path(s) for this page + var paths []string + if page == 1 { + // Page 1 renders at both root and /posts/ + paths = []string{"", "/posts"} + } else { + paths = []string{fmt.Sprintf("/posts/page/%d", page)} + } + + for _, path := range paths { + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePostList, pl) + }); err != nil { + return err + } + } + } + + return nil +} +``` + +- [ ] **Step 3: Update the post list template with prev/next links** + +Replace `layouts/simplecss/templates/posts_list.html`: +```html +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
+{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: Existing builder test may need updating (see next step). + +- [ ] **Step 5: Update builder test** + +The test in `providers/sitebuilder/builder_test.go` creates a `pubmodel.Site` without `PostsPerPage`, which will default to 0. Update the test site to set `PostsPerPage`: +```go +site := pubmodel.Site{ + Site: models.Site{PostsPerPage: 10}, + BaseURL: "https://example.com", + PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { + // ... existing code ... + }, +} +``` + +The expected `index.html` content stays the same since both posts fit on one page. + +- [ ] **Step 6: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/posts_list.html providers/sitebuilder/builder_test.go +git commit -m "feat: add pagination to generated site post list" +``` + +--- + +### Task 9: Add pagination to generated site category pages + +**Files:** +- Modify: `providers/sitebuilder/tmpls.go:82-88` (categorySingleData) +- Modify: `providers/sitebuilder/builder.go:315-362` (renderCategoryPages) +- Modify: `layouts/simplecss/templates/categories_single.html` + +- [ ] **Step 1: Update `categorySingleData` to include pagination** + +In `providers/sitebuilder/tmpls.go`, update `categorySingleData` (line 82): +```go +type categorySingleData struct { + commonData + Category *models.Category + DescriptionHTML template.HTML + Posts []postSingleData + Path string + PageInfo models.PageInfo + PrevURL string + NextURL string +} +``` + +- [ ] **Step 2: Rewrite `renderCategoryPages` to paginate** + +Replace `renderCategoryPages` in `providers/sitebuilder/builder.go` (line 315): +```go +func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error { + for _, cwc := range b.site.Categories { + if cwc.PostCount == 0 { + continue + } + + // Collect all posts for this category + var allPosts []postSingleData + for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) { + post, err := mp.Get() + if err != nil { + return err + } + rp, err := b.renderPostWithCategories(goCtx, post) + if err != nil { + return err + } + allPosts = append(allPosts, rp) + } + + var descHTML bytes.Buffer + if cwc.Description != "" { + if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil { + return err + } + } + + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 + } + + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 + } + + basePath := fmt.Sprintf("/categories/%s", cwc.Slug) + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = basePath + "/" + } else { + prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1) + } + } + if page < totalPages { + nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1) + } + + path := basePath + if page > 1 { + path = fmt.Sprintf("%s/page/%d", basePath, page) + } + + data := categorySingleData{ + commonData: commonData{Site: b.site}, + Category: &cwc.Category, + DescriptionHTML: template.HTML(descHTML.String()), + Posts: allPosts[start:end], + Path: path, + PageInfo: pageInfo, + PrevURL: prevURL, + NextURL: nextURL, + } + + if err := b.createAtPath(ctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNameCategorySingle, data) + }); err != nil { + return err + } + } + + // Per-category feeds (use all posts, not paginated) + if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil { + return err + } + } + + return nil +} +``` + +- [ ] **Step 3: Update category single template with prev/next links** + +Replace `layouts/simplecss/templates/categories_single.html`: +```html +{{ if .DescriptionHTML }}
{{ .DescriptionHTML }}
{{ end }} +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
+{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} +``` + +Note: check the current content of `categories_single.html` first — preserve any existing structure (like `

` headings) that may not have been captured in the exploration. Read the file before editing. + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/categories_single.html +git commit -m "feat: add pagination to generated site category pages" +``` + +--- + +### Task 10: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 2: Build the project** + +Run: `go build ./...` +Expected: Clean build with no errors. + +- [ ] **Step 3: Commit any remaining changes** + +If any files were missed, stage and commit them. -- 2.43.0 From 9b36a35c1a0ef455376482c6c63d8405cfac2892 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:26:09 +1100 Subject: [PATCH 03/13] feat: add posts_per_page column to sites table Co-Authored-By: Claude Sonnet 4.6 --- providers/db/gen/sqlgen/models.go | 15 ++++++----- providers/db/gen/sqlgen/sites.sql.go | 38 +++++++++++++++++----------- sql/queries/sites.sql | 5 ++-- sql/schema/05_posts_per_page.up.sql | 1 + 4 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 sql/schema/05_posts_per_page.up.sql diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 788c292..ae58594 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -57,13 +57,14 @@ type PublishTarget struct { } type Site struct { - ID int64 - OwnerID int64 - Guid string - Title string - Tagline string - CreatedAt int64 - Timezone string + ID int64 + OwnerID int64 + Guid string + Title string + Tagline string + CreatedAt int64 + Timezone string + PostsPerPage int64 } type Upload struct { diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index bd80fb3..80ccbc0 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -28,18 +28,20 @@ INSERT INTO sites ( title, tagline, timezone, + posts_per_page, created_at -) VALUES (?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id ` type InsertSiteParams struct { - OwnerID int64 - Guid string - Title string - Tagline string - Timezone string - CreatedAt int64 + OwnerID int64 + Guid string + Title string + Tagline string + Timezone string + PostsPerPage int64 + CreatedAt int64 } func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) { @@ -49,6 +51,7 @@ func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, arg.Title, arg.Tagline, arg.Timezone, + arg.PostsPerPage, arg.CreatedAt, ) var id int64 @@ -101,7 +104,7 @@ func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSite } const selectSiteByGUID = `-- name: SelectSiteByGUID :one -SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE guid = ? +SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE guid = ? ` func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) { @@ -115,12 +118,13 @@ func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, erro &i.Tagline, &i.CreatedAt, &i.Timezone, + &i.PostsPerPage, ) return i, err } const selectSiteByID = `-- name: SelectSiteByID :one -SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE id = ? +SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE id = ? ` func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { @@ -134,12 +138,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { &i.Tagline, &i.CreatedAt, &i.Timezone, + &i.PostsPerPage, ) return i, err } const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many -SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE owner_id = ? ORDER BY title ASC +SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE owner_id = ? ORDER BY title ASC ` func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) { @@ -159,6 +164,7 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] &i.Tagline, &i.CreatedAt, &i.Timezone, + &i.PostsPerPage, ); err != nil { return nil, err } @@ -174,14 +180,15 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] } const updateSite = `-- name: UpdateSite :exec -UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ? +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ? ` type UpdateSiteParams struct { - Title string - Tagline string - Timezone string - ID int64 + Title string + Tagline string + Timezone string + PostsPerPage int64 + ID int64 } func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error { @@ -189,6 +196,7 @@ func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error { arg.Title, arg.Tagline, arg.Timezone, + arg.PostsPerPage, arg.ID, ) return err diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index 8fe2469..0609b12 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -14,15 +14,16 @@ INSERT INTO sites ( title, tagline, timezone, + posts_per_page, created_at -) VALUES (?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id; -- name: HasUsersAndSites :one SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites; -- name: UpdateSite :exec -UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ?; +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; -- name: SelectAllSitesWithOwners :many SELECT s.id, s.guid, s.title, s.owner_id, u.username diff --git a/sql/schema/05_posts_per_page.up.sql b/sql/schema/05_posts_per_page.up.sql new file mode 100644 index 0000000..1bea8f9 --- /dev/null +++ b/sql/schema/05_posts_per_page.up.sql @@ -0,0 +1 @@ +ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; -- 2.43.0 From 9919f3444ad9d3a073eb08ed45ba4c6b1463f944 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:28:50 +1100 Subject: [PATCH 04/13] feat: add PostsPerPage to Site model and DB provider Co-Authored-By: Claude Sonnet 4.6 --- models/sites.go | 7 ++++--- providers/db/sites.go | 37 ++++++++++++++++++++----------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/models/sites.go b/models/sites.go index 16cbef4..81bf6be 100644 --- a/models/sites.go +++ b/models/sites.go @@ -27,9 +27,10 @@ type Site struct { GUID string Created time.Time - Title string - Tagline string - Timezone string + Title string + Tagline string + Timezone string + PostsPerPage int } type SitePublishTarget struct { diff --git a/providers/db/sites.go b/providers/db/sites.go index 28d83f6..d1167ca 100644 --- a/providers/db/sites.go +++ b/providers/db/sites.go @@ -42,12 +42,13 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ( func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { if site.ID == 0 { newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ - OwnerID: site.OwnerID, - Guid: site.GUID, - Title: site.Title, - Tagline: site.Tagline, - Timezone: site.Timezone, - CreatedAt: timeToInt(site.Created), + OwnerID: site.OwnerID, + Guid: site.GUID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + CreatedAt: timeToInt(site.Created), }) if err != nil { return err @@ -57,10 +58,11 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { } return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ - Title: site.Title, - Tagline: site.Tagline, - Timezone: site.Timezone, - ID: site.ID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + ID: site.ID, }) } @@ -101,12 +103,13 @@ func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwn func dbSiteToSite(row sqlgen.Site) models.Site { return models.Site{ - ID: row.ID, - OwnerID: row.OwnerID, - GUID: row.Guid, - Title: row.Title, - Timezone: row.Timezone, - Tagline: row.Tagline, - Created: time.Unix(row.CreatedAt, 0).UTC(), + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Timezone: row.Timezone, + Tagline: row.Tagline, + PostsPerPage: int(row.PostsPerPage), + Created: time.Unix(row.CreatedAt, 0).UTC(), } } -- 2.43.0 From 5bf77ede5c6e0631de299b38d13dea4205864020 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:30:57 +1100 Subject: [PATCH 05/13] feat: add CountPostsOfSite query and DB method Co-Authored-By: Claude Sonnet 4.6 --- providers/db/gen/sqlgen/posts.sql.go | 22 +++++++++++++++ providers/db/posts.go | 11 ++++++++ providers/db/provider_test.go | 40 ++++++++++++++++++++++++++++ sql/queries/posts.sql | 9 +++++++ 4 files changed, 82 insertions(+) diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 8bff191..ef3d170 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -9,6 +9,28 @@ import ( "context" ) +const countPostsOfSite = `-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = ?1 AND ( + CASE CAST (?2 AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +) +` + +type CountPostsOfSiteParams struct { + SiteID int64 + PostFilter string +} + +func (q *Queries) CountPostsOfSite(ctx context.Context, arg CountPostsOfSiteParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countPostsOfSite, arg.SiteID, arg.PostFilter) + var count int64 + err := row.Scan(&count) + return count, err +} + const hardDeletePost = `-- name: HardDeletePost :exec DELETE FROM posts WHERE id = ? ` diff --git a/providers/db/posts.go b/providers/db/posts.go index 218e931..7f58d1a 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -13,6 +13,17 @@ type PagingParams struct { Offset int64 } +func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) { + filter := "active" + if showDeleted { + filter = "deleted" + } + return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{ + SiteID: siteID, + PostFilter: filter, + }) +} + func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool, pp PagingParams) ([]*models.Post, error) { var filter = "" if showDeleted { diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index 06f03c0..0a2e6df 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -3,6 +3,7 @@ package db_test import ( "context" "encoding/base64" + "fmt" "path/filepath" "testing" "time" @@ -229,6 +230,45 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.Empty(t, posts) }) + + t.Run("count posts of site", func(t *testing.T) { + countSite := &models.Site{ + OwnerID: user.ID, + GUID: models.NewNanoID(), + Title: "Count Blog", + } + require.NoError(t, p.SaveSite(ctx, countSite)) + + now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC) + for i := 0; i < 3; i++ { + post := &models.Post{ + SiteID: countSite.ID, + GUID: models.NewNanoID(), + Title: fmt.Sprintf("Post %d", i), + Body: "body", + Slug: fmt.Sprintf("/post-%d", i), + CreatedAt: now, + } + require.NoError(t, p.SavePost(ctx, post)) + } + + count, err := p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(3), count) + + // Soft-delete one post + posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0}) + require.NoError(t, err) + require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID)) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + }) } func TestProvider_PublishTargets(t *testing.T) { diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index dae1f39..5a4c18e 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,3 +1,12 @@ +-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = sqlc.arg(site_id) AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +); + -- name: SelectPostsOfSite :many SELECT * FROM posts -- 2.43.0 From 113789a972358ae0f1e82acbea8586b074a74f2f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:32:02 +1100 Subject: [PATCH 06/13] feat: add PageInfo model for pagination Co-Authored-By: Claude Opus 4.6 --- models/paging.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 models/paging.go diff --git a/models/paging.go b/models/paging.go new file mode 100644 index 0000000..4ba0c9f --- /dev/null +++ b/models/paging.go @@ -0,0 +1,28 @@ +package models + +// PageInfo carries pagination state for templates. +type PageInfo struct { + CurrentPage int + TotalPages int + PostsPerPage int +} + +// HasPrevious returns true if there is a previous page. +func (p PageInfo) HasPrevious() bool { + return p.CurrentPage > 1 +} + +// HasNext returns true if there is a next page. +func (p PageInfo) HasNext() bool { + return p.CurrentPage < p.TotalPages +} + +// PreviousPage returns the previous page number. +func (p PageInfo) PreviousPage() int { + return p.CurrentPage - 1 +} + +// NextPage returns the next page number. +func (p PageInfo) NextPage() int { + return p.CurrentPage + 1 +} -- 2.43.0 From 82feccf64aab94a2c85b7459aa40028d20e8a8fd Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:33:31 +1100 Subject: [PATCH 07/13] feat: add pagination to admin post list handler and service Co-Authored-By: Claude Opus 4.6 --- handlers/posts.go | 30 ++++++++++++++++++++++++++---- services/posts/list.go | 25 ++++++++++++++++--------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/handlers/posts.go b/handlers/posts.go index a133758..3326533 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -6,6 +6,7 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/posts" ) @@ -18,22 +19,43 @@ type PostsHandler struct { func (ph PostsHandler) Index(c fiber.Ctx) error { var req struct { Filter string `query:"filter"` + Page int `query:"page"` } if err := c.Bind().Query(&req); err != nil { return fiber.ErrBadRequest } - posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted") + const perPage = 25 + if req.Page < 1 { + req.Page = 1 + } + + result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{ + Offset: int64((req.Page - 1) * perPage), + Limit: perPage, + }) if err != nil { return err } + totalPages := int(result.TotalCount+int64(perPage)-1) / perPage + if totalPages < 1 { + totalPages = 1 + } + + pageInfo := models.PageInfo{ + CurrentPage: req.Page, + TotalPages: totalPages, + PostsPerPage: perPage, + } + return accepts(c, json(func() any { - return posts + return result.Posts }), html(func(c fiber.Ctx) error { return c.Render("posts/index", fiber.Map{ - "req": req, - "posts": posts, + "req": req, + "posts": result.Posts, + "pageInfo": pageInfo, }) })) } diff --git a/services/posts/list.go b/services/posts/list.go index 15e14d3..dd25bae 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -12,29 +12,36 @@ type PostWithCategories struct { Categories []*models.Category } -func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { +type ListPostsResult struct { + Posts []*PostWithCategories + TotalCount int64 +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { site, ok := models.GetSite(ctx) if !ok { - return nil, models.SiteRequiredError + return ListPostsResult{}, models.SiteRequiredError } - posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ - Offset: 0, - Limit: 25, - }) + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) if err != nil { - return nil, err + return ListPostsResult{}, err + } + + count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) + if err != nil { + return ListPostsResult{}, err } result := make([]*PostWithCategories, len(posts)) for i, post := range posts { cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) if err != nil { - return nil, err + return ListPostsResult{}, err } result[i] = &PostWithCategories{Post: post, Categories: cats} } - return result, nil + return ListPostsResult{Posts: result, TotalCount: count}, nil } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { -- 2.43.0 From d7a5d425b8361e2b5b2b381f773b5393fa43d7b8 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:34:55 +1100 Subject: [PATCH 08/13] feat: add pagination controls to admin post list Co-Authored-By: Claude Opus 4.6 --- models/paging.go | 9 +++++++++ views/posts/index.html | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/models/paging.go b/models/paging.go index 4ba0c9f..b4e514b 100644 --- a/models/paging.go +++ b/models/paging.go @@ -26,3 +26,12 @@ func (p PageInfo) PreviousPage() int { func (p PageInfo) NextPage() int { return p.CurrentPage + 1 } + +// Pages returns a slice of page numbers for rendering numbered pagination. +func (p PageInfo) Pages() []int { + pages := make([]int, p.TotalPages) + for i := range pages { + pages[i] = i + 1 + } + return pages +} diff --git a/views/posts/index.html b/views/posts/index.html index bbf445d..7786539 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -62,4 +62,22 @@ {{ end }} {{ end }} + + {{ if gt .pageInfo.TotalPages 1 }} + + {{ end }} \ No newline at end of file -- 2.43.0 From 550ebf728aaf4e6439be2da1016ef50abcd10d80 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:35:56 +1100 Subject: [PATCH 09/13] feat: add posts per page setting to site settings Co-Authored-By: Claude Sonnet 4.6 --- services/sites/services.go | 17 +++++++++++++---- views/sitesettings/general.html | 7 +++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/services/sites/services.go b/services/sites/services.go index 06afe15..4c974bb 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -129,10 +129,11 @@ func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwne } type UpdateSiteSettingsParams struct { - SiteID int64 `form:"siteID"` - Name string `form:"name"` - Tagline string `form:"tagline"` - Timezone string `form:"timezone"` + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` + PostsPerPage int `form:"postsPerPage"` } func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { @@ -146,9 +147,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti return models.Site{}, errors.Wrap(err, "invalid timezone") } + postsPerPage := params.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 1 + } else if postsPerPage > 100 { + postsPerPage = 100 + } + site.Title = params.Name site.Tagline = params.Tagline site.Timezone = params.Timezone + site.PostsPerPage = postsPerPage if err := s.db.SaveSite(ctx, &site); err != nil { return models.Site{}, err diff --git a/views/sitesettings/general.html b/views/sitesettings/general.html index ca3e7a9..6f1833b 100644 --- a/views/sitesettings/general.html +++ b/views/sitesettings/general.html @@ -41,6 +41,13 @@ +
+ +
+ +
Number of posts per page on the generated site.
+
+
-- 2.43.0 From 30884372d6db048c92be996181b1a90dd5066677 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:37:42 +1100 Subject: [PATCH 10/13] feat: add pagination to generated site post list Co-Authored-By: Claude Opus 4.6 --- layouts/simplecss/templates/posts_list.html | 8 ++- providers/sitebuilder/builder.go | 70 ++++++++++++++++++--- providers/sitebuilder/builder_test.go | 1 + providers/sitebuilder/tmpls.go | 5 +- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/layouts/simplecss/templates/posts_list.html b/layouts/simplecss/templates/posts_list.html index 5f10f1e..6a2eca6 100644 --- a/layouts/simplecss/templates/posts_list.html +++ b/layouts/simplecss/templates/posts_list.html @@ -5,4 +5,10 @@ {{ template "_post_meta.html" . }}
-{{ end }} \ No newline at end of file +{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 1a4275d..601db41 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -122,7 +122,8 @@ func (b *Builder) BuildSite(outDir string) error { } func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { - var posts []postSingleData + // Collect all posts + var allPosts []postSingleData for mp := range b.site.PostIter(ctx) { post, err := mp.Get() if err != nil { @@ -132,17 +133,70 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co if err != nil { return err } - posts = append(posts, rp) + allPosts = append(allPosts, rp) } - pl := postListData{ - commonData: commonData{Site: b.site}, - Posts: posts, + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 } - return b.createAtPath(bctx, "", func(f io.Writer) error { - return b.renderTemplate(f, tmplNamePostList, pl) - }) + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 + } + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = "/posts/" + } else { + prevURL = fmt.Sprintf("/posts/page/%d/", page-1) + } + } + if page < totalPages { + nextURL = fmt.Sprintf("/posts/page/%d/", page+1) + } + + pl := postListData{ + commonData: commonData{Site: b.site}, + Posts: allPosts[start:end], + PageInfo: pageInfo, + PrevURL: prevURL, + NextURL: nextURL, + } + + // Page 1 renders at both root and /posts/ + var paths []string + if page == 1 { + paths = []string{"", "/posts"} + } else { + paths = []string{fmt.Sprintf("/posts/page/%d", page)} + } + + for _, path := range paths { + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePostList, pl) + }); err != nil { + return err + } + } + } + + return nil } func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]], opts feedOptions) error { diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index cbe116b..a5a9bbf 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -38,6 +38,7 @@ func TestBuilder_BuildSite(t *testing.T) { } site := pubmodel.Site{ + Site: models.Site{PostsPerPage: 10}, BaseURL: "https://example.com", PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index cea02f5..2ba1c74 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -61,7 +61,10 @@ type postSingleData struct { type postListData struct { commonData - Posts []postSingleData + Posts []postSingleData + PageInfo models.PageInfo + PrevURL string + NextURL string } type layoutData struct { -- 2.43.0 From f68bac809ffb500765d6f4e81f68edb05bd48e05 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:38:39 +1100 Subject: [PATCH 11/13] feat: add pagination to generated site category pages Co-Authored-By: Claude Opus 4.6 --- .../templates/categories_single.html | 8 +- providers/sitebuilder/builder.go | 75 +++++++++++++++---- providers/sitebuilder/tmpls.go | 3 + 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html index deaeb02..e9e7116 100644 --- a/layouts/simplecss/templates/categories_single.html +++ b/layouts/simplecss/templates/categories_single.html @@ -8,4 +8,10 @@ {{ .HTML }} {{ template "_post_meta.html" . }} -{{ end }} \ No newline at end of file +{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 601db41..f18e00d 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -372,7 +372,8 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e continue } - var posts []postSingleData + // Collect all posts for this category + var allPosts []postSingleData for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) { post, err := mp.Get() if err != nil { @@ -382,7 +383,7 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e if err != nil { return err } - posts = append(posts, rp) + allPosts = append(allPosts, rp) } var descHTML bytes.Buffer @@ -392,22 +393,68 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e } } - data := categorySingleData{ - commonData: commonData{Site: b.site}, - Category: &cwc.Category, - DescriptionHTML: template.HTML(descHTML.String()), - Posts: posts, - Path: fmt.Sprintf("/categories/%s", cwc.Slug), + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 } - if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error { - return b.renderTemplate(f, tmplNameCategorySingle, data) - }); err != nil { - return err + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 } - // Per-category feeds - if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil { + basePath := fmt.Sprintf("/categories/%s", cwc.Slug) + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = basePath + "/" + } else { + prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1) + } + } + if page < totalPages { + nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1) + } + + path := basePath + if page > 1 { + path = fmt.Sprintf("%s/page/%d", basePath, page) + } + + data := categorySingleData{ + commonData: commonData{Site: b.site}, + Category: &cwc.Category, + DescriptionHTML: template.HTML(descHTML.String()), + Posts: allPosts[start:end], + Path: path, + PageInfo: pageInfo, + PrevURL: prevURL, + NextURL: nextURL, + } + + if err := b.createAtPath(ctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNameCategorySingle, data) + }); err != nil { + return err + } + } + + // Per-category feeds (use all posts, not paginated) + if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil { return err } } diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index 2ba1c74..e0ece37 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -88,4 +88,7 @@ type categorySingleData struct { DescriptionHTML template.HTML Posts []postSingleData Path string + PageInfo models.PageInfo + PrevURL string + NextURL string } -- 2.43.0 From 40da63368a37c9651497c51b3896846dcad195bb Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:41:50 +1100 Subject: [PATCH 12/13] fix: add nil guard for StaticFS and set default PostsPerPage in FirstRun Co-Authored-By: Claude Opus 4.6 --- providers/sitebuilder/builder.go | 3 +++ services/sites/services.go | 11 ++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index f18e00d..7523d13 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -572,6 +572,9 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error } func (b *Builder) writeStaticAssets(ctx buildContext) error { + if b.opts.StaticFS == nil { + return nil + } return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error { if err != nil { return err diff --git a/services/sites/services.go b/services/sites/services.go index 4c974bb..86e34b2 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -77,11 +77,12 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo } newSite = models.Site{ - Title: defaultIfEmpty(req.SiteName, "New Site"), - GUID: models.NewNanoID(), - OwnerID: newUser.ID, - Timezone: "UTC", - Created: time.Now(), + Title: defaultIfEmpty(req.SiteName, "New Site"), + GUID: models.NewNanoID(), + OwnerID: newUser.ID, + Timezone: "UTC", + PostsPerPage: 10, + Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { return newUser, newSite, err -- 2.43.0 From 0a1631a7e0beef2e3680605fa9dfaf28c9633372 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 16:22:32 +1100 Subject: [PATCH 13/13] Fixed paging URL --- providers/sitebuilder/builder.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 7523d13..9e5199d 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -164,11 +164,11 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co if page == 2 { prevURL = "/posts/" } else { - prevURL = fmt.Sprintf("/posts/page/%d/", page-1) + prevURL = fmt.Sprintf("/posts/%d/", page-1) } } if page < totalPages { - nextURL = fmt.Sprintf("/posts/page/%d/", page+1) + nextURL = fmt.Sprintf("/posts/%d/", page+1) } pl := postListData{ @@ -184,7 +184,7 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co if page == 1 { paths = []string{"", "/posts"} } else { - paths = []string{fmt.Sprintf("/posts/page/%d", page)} + paths = []string{fmt.Sprintf("/posts/%d", page)} } for _, path := range paths { @@ -423,16 +423,16 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e if page == 2 { prevURL = basePath + "/" } else { - prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1) + prevURL = fmt.Sprintf("%s/%d/", basePath, page-1) } } if page < totalPages { - nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1) + nextURL = fmt.Sprintf("%s/%d/", basePath, page+1) } path := basePath if page > 1 { - path = fmt.Sprintf("%s/page/%d", basePath, page) + path = fmt.Sprintf("%s/%d", basePath, page) } data := categorySingleData{ -- 2.43.0