Compare commits
10 commits
7c4dc0885e
...
40da63368a
| Author | SHA1 | Date | |
|---|---|---|---|
| 40da63368a | |||
| f68bac809f | |||
| 30884372d6 | |||
| 550ebf728a | |||
| d7a5d425b8 | |||
| 82feccf64a | |||
| 113789a972 | |||
| 5bf77ede5c | |||
| 9919f3444a | |||
| 9b36a35c1a |
21 changed files with 412 additions and 91 deletions
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 }}
|
||||||
|
|
|
||||||
|
|
@ -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
37
models/paging.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -27,9 +27,10 @@ type Site struct {
|
||||||
GUID string
|
GUID string
|
||||||
Created time.Time
|
Created time.Time
|
||||||
|
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
Timezone string
|
Timezone string
|
||||||
|
PostsPerPage int
|
||||||
}
|
}
|
||||||
|
|
||||||
type SitePublishTarget struct {
|
type SitePublishTarget struct {
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,14 @@ type PublishTarget struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Site struct {
|
type Site struct {
|
||||||
ID int64
|
ID int64
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
Guid string
|
Guid string
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
CreatedAt int64
|
CreatedAt int64
|
||||||
Timezone string
|
Timezone string
|
||||||
|
PostsPerPage int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Upload struct {
|
type Upload struct {
|
||||||
|
|
|
||||||
|
|
@ -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 = ?
|
||||||
`
|
`
|
||||||
|
|
|
||||||
|
|
@ -28,18 +28,20 @@ INSERT INTO sites (
|
||||||
title,
|
title,
|
||||||
tagline,
|
tagline,
|
||||||
timezone,
|
timezone,
|
||||||
|
posts_per_page,
|
||||||
created_at
|
created_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`
|
`
|
||||||
|
|
||||||
type InsertSiteParams struct {
|
type InsertSiteParams struct {
|
||||||
OwnerID int64
|
OwnerID int64
|
||||||
Guid string
|
Guid string
|
||||||
Title string
|
Title string
|
||||||
Tagline string
|
Tagline string
|
||||||
Timezone string
|
Timezone string
|
||||||
CreatedAt int64
|
PostsPerPage int64
|
||||||
|
CreatedAt int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
|
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.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,14 +180,15 @@ 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
|
||||||
ID int64
|
PostsPerPage int64
|
||||||
|
ID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error {
|
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.Title,
|
||||||
arg.Tagline,
|
arg.Tagline,
|
||||||
arg.Timezone,
|
arg.Timezone,
|
||||||
|
arg.PostsPerPage,
|
||||||
arg.ID,
|
arg.ID,
|
||||||
)
|
)
|
||||||
return err
|
return err
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -42,12 +42,13 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
|
||||||
func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
||||||
if site.ID == 0 {
|
if site.ID == 0 {
|
||||||
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
|
||||||
OwnerID: site.OwnerID,
|
OwnerID: site.OwnerID,
|
||||||
Guid: site.GUID,
|
Guid: site.GUID,
|
||||||
Title: site.Title,
|
Title: site.Title,
|
||||||
Tagline: site.Tagline,
|
Tagline: site.Tagline,
|
||||||
Timezone: site.Timezone,
|
Timezone: site.Timezone,
|
||||||
CreatedAt: timeToInt(site.Created),
|
PostsPerPage: int64(site.PostsPerPage),
|
||||||
|
CreatedAt: timeToInt(site.Created),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -57,10 +58,11 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{
|
return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{
|
||||||
Title: site.Title,
|
Title: site.Title,
|
||||||
Tagline: site.Tagline,
|
Tagline: site.Tagline,
|
||||||
Timezone: site.Timezone,
|
Timezone: site.Timezone,
|
||||||
ID: site.ID,
|
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 {
|
func dbSiteToSite(row sqlgen.Site) models.Site {
|
||||||
return models.Site{
|
return models.Site{
|
||||||
ID: row.ID,
|
ID: row.ID,
|
||||||
OwnerID: row.OwnerID,
|
OwnerID: row.OwnerID,
|
||||||
GUID: row.Guid,
|
GUID: row.Guid,
|
||||||
Title: row.Title,
|
Title: row.Title,
|
||||||
Timezone: row.Timezone,
|
Timezone: row.Timezone,
|
||||||
Tagline: row.Tagline,
|
Tagline: row.Tagline,
|
||||||
Created: time.Unix(row.CreatedAt, 0).UTC(),
|
PostsPerPage: int(row.PostsPerPage),
|
||||||
|
Created: time.Unix(row.CreatedAt, 0).UTC(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
pl := postListData{
|
postsPerPage := b.site.PostsPerPage
|
||||||
commonData: commonData{Site: b.site},
|
if postsPerPage < 1 {
|
||||||
Posts: posts,
|
postsPerPage = 10
|
||||||
}
|
}
|
||||||
|
|
||||||
return b.createAtPath(bctx, "", func(f io.Writer) error {
|
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
|
||||||
return b.renderTemplate(f, tmplNamePostList, pl)
|
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 {
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data := categorySingleData{
|
postsPerPage := b.site.PostsPerPage
|
||||||
commonData: commonData{Site: b.site},
|
if postsPerPage < 1 {
|
||||||
Category: &cwc.Category,
|
postsPerPage = 10
|
||||||
DescriptionHTML: template.HTML(descHTML.String()),
|
|
||||||
Posts: posts,
|
|
||||||
Path: fmt.Sprintf("/categories/%s", cwc.Slug),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error {
|
totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage
|
||||||
return b.renderTemplate(f, tmplNameCategorySingle, data)
|
if totalPages < 1 {
|
||||||
}); err != nil {
|
totalPages = 1
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-category feeds
|
basePath := fmt.Sprintf("/categories/%s", cwc.Slug)
|
||||||
if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil {
|
|
||||||
|
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 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
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,10 @@ 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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -77,11 +77,12 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
|
||||||
}
|
}
|
||||||
|
|
||||||
newSite = models.Site{
|
newSite = models.Site{
|
||||||
Title: defaultIfEmpty(req.SiteName, "New Site"),
|
Title: defaultIfEmpty(req.SiteName, "New Site"),
|
||||||
GUID: models.NewNanoID(),
|
GUID: models.NewNanoID(),
|
||||||
OwnerID: newUser.ID,
|
OwnerID: newUser.ID,
|
||||||
Timezone: "UTC",
|
Timezone: "UTC",
|
||||||
Created: time.Now(),
|
PostsPerPage: 10,
|
||||||
|
Created: time.Now(),
|
||||||
}
|
}
|
||||||
if err := s.db.SaveSite(ctx, &newSite); err != nil {
|
if err := s.db.SaveSite(ctx, &newSite); err != nil {
|
||||||
return newUser, newSite, err
|
return newUser, newSite, err
|
||||||
|
|
@ -129,10 +130,11 @@ func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwne
|
||||||
}
|
}
|
||||||
|
|
||||||
type UpdateSiteSettingsParams struct {
|
type UpdateSiteSettingsParams struct {
|
||||||
SiteID int64 `form:"siteID"`
|
SiteID int64 `form:"siteID"`
|
||||||
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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
1
sql/schema/05_posts_per_page.up.sql
Normal file
1
sql/schema/05_posts_per_page.up.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10;
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue