Paging #4

Merged
lmika merged 13 commits from feature/pages-and-paging into main 2026-03-22 05:23:54 +00:00
23 changed files with 1400 additions and 91 deletions

View file

@ -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 `</main>` tag:
```html
{{ if gt .pageInfo.TotalPages 1 }}
<nav aria-label="Page navigation" class="my-4">
<ul class="pagination justify-content-center">
<li class="page-item{{ if not .pageInfo.HasPrevious }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.PreviousPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Previous</a>
</li>
{{ range $p := .pageInfo.Pages }}
<li class="page-item{{ if eq $p $.pageInfo.CurrentPage }} active{{ end }}">
<a class="page-link" href="?page={{ $p }}{{ if $.req.Filter }}&filter={{ $.req.Filter }}{{ end }}">{{ $p }}</a>
</li>
{{ end }}
<li class="page-item{{ if not .pageInfo.HasNext }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.NextPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Next</a>
</li>
</ul>
</nav>
{{ 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
<div class="row mb-3">
<label for="postsPerPage" class="col-sm-3 col-form-label text-end">Posts Per Page</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="postsPerPage" name="postsPerPage" value="{{ .site.PostsPerPage }}" min="1" max="100">
<div class="form-text">Number of posts per page on the generated site.</div>
</div>
</div>
```
- [ ] **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 }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ 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 }}<div class="category-description">{{ .DescriptionHTML }}</div>{{ end }}
{{ range .Posts }}
<div class="h-entry">
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
{{ .HTML }}
{{ template "_post_meta.html" . }}
</div>
{{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}
```
Note: check the current content of `categories_single.html` first — preserve any existing structure (like `<h2>` 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.

View file

@ -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/<slug>/` — page 1
- `/categories/<slug>/page/2/` — page 2
- `/categories/<slug>/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`.

View file

@ -6,6 +6,7 @@ import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/categories"
"lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/posts"
) )
@ -18,22 +19,43 @@ type PostsHandler struct {
func (ph PostsHandler) Index(c fiber.Ctx) error { func (ph PostsHandler) Index(c fiber.Ctx) error {
var req struct { var req struct {
Filter string `query:"filter"` Filter string `query:"filter"`
Page int `query:"page"`
} }
if err := c.Bind().Query(&req); err != nil { if err := c.Bind().Query(&req); err != nil {
return fiber.ErrBadRequest 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 { if err != nil {
return err 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 accepts(c, json(func() any {
return posts return result.Posts
}), html(func(c fiber.Ctx) error { }), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{ return c.Render("posts/index", fiber.Map{
"req": req, "req": req,
"posts": posts, "posts": result.Posts,
"pageInfo": pageInfo,
}) })
})) }))
} }

View file

@ -9,3 +9,9 @@
{{ template "_post_meta.html" . }} {{ template "_post_meta.html" . }}
</div> </div>
{{ end }} {{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}

View file

@ -6,3 +6,9 @@
{{ template "_post_meta.html" . }} {{ template "_post_meta.html" . }}
</div> </div>
{{ end }} {{ end }}
{{ if or .PrevURL .NextURL }}
<nav class="pagination">
{{ if .PrevURL }}<a href="{{ .PrevURL }}">← Newer posts</a>{{ end }}
{{ if .NextURL }}<a href="{{ .NextURL }}">Older posts →</a>{{ end }}
</nav>
{{ end }}

37
models/paging.go Normal file
View file

@ -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
}

View file

@ -30,6 +30,7 @@ type Site struct {
Title string Title string
Tagline string Tagline string
Timezone string Timezone string
PostsPerPage int
} }
type SitePublishTarget struct { type SitePublishTarget struct {

View file

@ -64,6 +64,7 @@ type Site struct {
Tagline string Tagline string
CreatedAt int64 CreatedAt int64
Timezone string Timezone string
PostsPerPage int64
} }
type Upload struct { type Upload struct {

View file

@ -9,6 +9,28 @@ import (
"context" "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 const hardDeletePost = `-- name: HardDeletePost :exec
DELETE FROM posts WHERE id = ? DELETE FROM posts WHERE id = ?
` `

View file

@ -28,8 +28,9 @@ INSERT INTO sites (
title, title,
tagline, tagline,
timezone, timezone,
posts_per_page,
created_at created_at
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id RETURNING id
` `
@ -39,6 +40,7 @@ type InsertSiteParams struct {
Title string Title string
Tagline string Tagline string
Timezone string Timezone string
PostsPerPage int64
CreatedAt int64 CreatedAt int64
} }
@ -49,6 +51,7 @@ func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64,
arg.Title, arg.Title,
arg.Tagline, arg.Tagline,
arg.Timezone, arg.Timezone,
arg.PostsPerPage,
arg.CreatedAt, arg.CreatedAt,
) )
var id int64 var id int64
@ -101,7 +104,7 @@ func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSite
} }
const selectSiteByGUID = `-- name: SelectSiteByGUID :one 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) { 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.Tagline,
&i.CreatedAt, &i.CreatedAt,
&i.Timezone, &i.Timezone,
&i.PostsPerPage,
) )
return i, err return i, err
} }
const selectSiteByID = `-- name: SelectSiteByID :one 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) { 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.Tagline,
&i.CreatedAt, &i.CreatedAt,
&i.Timezone, &i.Timezone,
&i.PostsPerPage,
) )
return i, err return i, err
} }
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many 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) { 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.Tagline,
&i.CreatedAt, &i.CreatedAt,
&i.Timezone, &i.Timezone,
&i.PostsPerPage,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -174,13 +180,14 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
} }
const updateSite = `-- name: UpdateSite :exec 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 { type UpdateSiteParams struct {
Title string Title string
Tagline string Tagline string
Timezone string Timezone string
PostsPerPage int64
ID int64 ID int64
} }
@ -189,6 +196,7 @@ func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error {
arg.Title, arg.Title,
arg.Tagline, arg.Tagline,
arg.Timezone, arg.Timezone,
arg.PostsPerPage,
arg.ID, arg.ID,
) )
return err return err

View file

@ -13,6 +13,17 @@ type PagingParams struct {
Offset int64 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) { func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool, pp PagingParams) ([]*models.Post, error) {
var filter = "" var filter = ""
if showDeleted { if showDeleted {

View file

@ -3,6 +3,7 @@ package db_test
import ( import (
"context" "context"
"encoding/base64" "encoding/base64"
"fmt"
"path/filepath" "path/filepath"
"testing" "testing"
"time" "time"
@ -229,6 +230,45 @@ func TestProvider_Posts(t *testing.T) {
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, posts) 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) { func TestProvider_PublishTargets(t *testing.T) {

View file

@ -47,6 +47,7 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
Title: site.Title, Title: site.Title,
Tagline: site.Tagline, Tagline: site.Tagline,
Timezone: site.Timezone, Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
CreatedAt: timeToInt(site.Created), CreatedAt: timeToInt(site.Created),
}) })
if err != nil { if err != nil {
@ -60,6 +61,7 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
Title: site.Title, Title: site.Title,
Tagline: site.Tagline, Tagline: site.Tagline,
Timezone: site.Timezone, Timezone: site.Timezone,
PostsPerPage: int64(site.PostsPerPage),
ID: site.ID, ID: site.ID,
}) })
} }
@ -107,6 +109,7 @@ func dbSiteToSite(row sqlgen.Site) models.Site {
Title: row.Title, Title: row.Title,
Timezone: row.Timezone, Timezone: row.Timezone,
Tagline: row.Tagline, Tagline: row.Tagline,
PostsPerPage: int(row.PostsPerPage),
Created: time.Unix(row.CreatedAt, 0).UTC(), Created: time.Unix(row.CreatedAt, 0).UTC(),
} }
} }

View file

@ -122,7 +122,8 @@ func (b *Builder) BuildSite(outDir string) error {
} }
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) 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) { for mp := range b.site.PostIter(ctx) {
post, err := mp.Get() post, err := mp.Get()
if err != nil { if err != nil {
@ -132,17 +133,70 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co
if err != nil { if err != nil {
return err return err
} }
posts = append(posts, rp) 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/%d/", page-1)
}
}
if page < totalPages {
nextURL = fmt.Sprintf("/posts/%d/", page+1)
} }
pl := postListData{ pl := postListData{
commonData: commonData{Site: b.site}, commonData: commonData{Site: b.site},
Posts: posts, Posts: allPosts[start:end],
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
} }
return b.createAtPath(bctx, "", func(f io.Writer) error { // 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) 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 { 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 continue
} }
var posts []postSingleData // Collect all posts for this category
var allPosts []postSingleData
for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) { for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) {
post, err := mp.Get() post, err := mp.Get()
if err != nil { if err != nil {
@ -328,7 +383,7 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e
if err != nil { if err != nil {
return err return err
} }
posts = append(posts, rp) allPosts = append(allPosts, rp)
} }
var descHTML bytes.Buffer var descHTML bytes.Buffer
@ -338,22 +393,68 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e
} }
} }
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/%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{ data := categorySingleData{
commonData: commonData{Site: b.site}, commonData: commonData{Site: b.site},
Category: &cwc.Category, Category: &cwc.Category,
DescriptionHTML: template.HTML(descHTML.String()), DescriptionHTML: template.HTML(descHTML.String()),
Posts: posts, Posts: allPosts[start:end],
Path: fmt.Sprintf("/categories/%s", cwc.Slug), Path: path,
PageInfo: pageInfo,
PrevURL: prevURL,
NextURL: nextURL,
} }
if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error { if err := b.createAtPath(ctx, path, func(f io.Writer) error {
return b.renderTemplate(f, tmplNameCategorySingle, data) return b.renderTemplate(f, tmplNameCategorySingle, data)
}); err != nil { }); err != nil {
return err return err
} }
}
// Per-category feeds // Per-category feeds (use all posts, not paginated)
if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil { if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil {
return err return err
} }
} }
@ -471,6 +572,9 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error
} }
func (b *Builder) writeStaticAssets(ctx buildContext) 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 { return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error {
if err != nil { if err != nil {
return err return err

View file

@ -38,6 +38,7 @@ func TestBuilder_BuildSite(t *testing.T) {
} }
site := pubmodel.Site{ site := pubmodel.Site{
Site: models.Site{PostsPerPage: 10},
BaseURL: "https://example.com", BaseURL: "https://example.com",
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
return func(yield func(models.Maybe[*models.Post]) bool) { return func(yield func(models.Maybe[*models.Post]) bool) {

View file

@ -62,6 +62,9 @@ type postSingleData struct {
type postListData struct { type postListData struct {
commonData commonData
Posts []postSingleData Posts []postSingleData
PageInfo models.PageInfo
PrevURL string
NextURL string
} }
type layoutData struct { type layoutData struct {
@ -85,4 +88,7 @@ type categorySingleData struct {
DescriptionHTML template.HTML DescriptionHTML template.HTML
Posts []postSingleData Posts []postSingleData
Path string Path string
PageInfo models.PageInfo
PrevURL string
NextURL string
} }

View file

@ -12,29 +12,36 @@ type PostWithCategories struct {
Categories []*models.Category 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) site, ok := models.GetSite(ctx)
if !ok { if !ok {
return nil, models.SiteRequiredError return ListPostsResult{}, models.SiteRequiredError
} }
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging)
Offset: 0,
Limit: 25,
})
if err != nil { 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)) result := make([]*PostWithCategories, len(posts))
for i, post := range posts { for i, post := range posts {
cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID)
if err != nil { if err != nil {
return nil, err return ListPostsResult{}, err
} }
result[i] = &PostWithCategories{Post: post, Categories: cats} 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) { func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {

View file

@ -81,6 +81,7 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
GUID: models.NewNanoID(), GUID: models.NewNanoID(),
OwnerID: newUser.ID, OwnerID: newUser.ID,
Timezone: "UTC", Timezone: "UTC",
PostsPerPage: 10,
Created: time.Now(), Created: time.Now(),
} }
if err := s.db.SaveSite(ctx, &newSite); err != nil { if err := s.db.SaveSite(ctx, &newSite); err != nil {
@ -133,6 +134,7 @@ type UpdateSiteSettingsParams struct {
Name string `form:"name"` Name string `form:"name"`
Tagline string `form:"tagline"` Tagline string `form:"tagline"`
Timezone string `form:"timezone"` Timezone string `form:"timezone"`
PostsPerPage int `form:"postsPerPage"`
} }
func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { 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") 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.Title = params.Name
site.Tagline = params.Tagline site.Tagline = params.Tagline
site.Timezone = params.Timezone site.Timezone = params.Timezone
site.PostsPerPage = postsPerPage
if err := s.db.SaveSite(ctx, &site); err != nil { if err := s.db.SaveSite(ctx, &site); err != nil {
return models.Site{}, err return models.Site{}, err

View file

@ -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 -- name: SelectPostsOfSite :many
SELECT * SELECT *
FROM posts FROM posts

View file

@ -14,15 +14,16 @@ INSERT INTO sites (
title, title,
tagline, tagline,
timezone, timezone,
posts_per_page,
created_at created_at
) VALUES (?, ?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?, ?)
RETURNING id; RETURNING id;
-- name: HasUsersAndSites :one -- name: HasUsersAndSites :one
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites; SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;
-- name: UpdateSite :exec -- 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 -- name: SelectAllSitesWithOwners :many
SELECT s.id, s.guid, s.title, s.owner_id, u.username SELECT s.id, s.guid, s.title, s.owner_id, u.username

View file

@ -0,0 +1 @@
ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10;

View file

@ -62,4 +62,22 @@
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ if gt .pageInfo.TotalPages 1 }}
<nav aria-label="Page navigation" class="my-4">
<ul class="pagination justify-content-center">
<li class="page-item{{ if not .pageInfo.HasPrevious }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.PreviousPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Previous</a>
</li>
{{ range $p := .pageInfo.Pages }}
<li class="page-item{{ if eq $p $.pageInfo.CurrentPage }} active{{ end }}">
<a class="page-link" href="?page={{ $p }}{{ if $.req.Filter }}&filter={{ $.req.Filter }}{{ end }}">{{ $p }}</a>
</li>
{{ end }}
<li class="page-item{{ if not .pageInfo.HasNext }} disabled{{ end }}">
<a class="page-link" href="?page={{ .pageInfo.NextPage }}{{ if .req.Filter }}&filter={{ .req.Filter }}{{ end }}">Next</a>
</li>
</ul>
</nav>
{{ end }}
</main> </main>

View file

@ -41,6 +41,13 @@
</datalist> </datalist>
</div> </div>
</div> </div>
<div class="row mb-3">
<label for="postsPerPage" class="col-sm-3 col-form-label text-end">Posts Per Page</label>
<div class="col-sm-3">
<input type="number" class="form-control" id="postsPerPage" name="postsPerPage" value="{{ .site.PostsPerPage }}" min="1" max="100">
<div class="form-text">Number of posts per page on the generated site.</div>
</div>
</div>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-sm-3"></div> <div class="col-sm-3"></div>
<div class="col-sm-9"><button type="submit" class="btn btn-primary">Save Settings</button></div> <div class="col-sm-9"><button type="submit" class="btn btn-primary">Save Settings</button></div>