+{{ 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.
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`.
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/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/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/models/paging.go b/models/paging.go
new file mode 100644
index 0000000..b4e514b
--- /dev/null
+++ b/models/paging.go
@@ -0,0 +1,37 @@
+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
+}
+
+// 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/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/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/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/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/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/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(),
}
}
diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go
index 1a4275d..9e5199d 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/%d/", page-1)
+ }
+ }
+ if page < totalPages {
+ nextURL = fmt.Sprintf("/posts/%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/%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 {
@@ -318,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 {
@@ -328,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
@@ -338,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/%d/", basePath, page-1)
+ }
+ }
+ if page < totalPages {
+ nextURL = fmt.Sprintf("%s/%d/", basePath, page+1)
+ }
+
+ path := basePath
+ if page > 1 {
+ path = fmt.Sprintf("%s/%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
}
}
@@ -471,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/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..e0ece37 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 {
@@ -85,4 +88,7 @@ type categorySingleData struct {
DescriptionHTML template.HTML
Posts []postSingleData
Path string
+ PageInfo models.PageInfo
+ PrevURL string
+ NextURL string
}
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) {
diff --git a/services/sites/services.go b/services/sites/services.go
index 06afe15..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
@@ -129,10 +130,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 +148,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/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
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;
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
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 @@
+