2037 lines
54 KiB
Markdown
2037 lines
54 KiB
Markdown
|
|
# Categories 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 flat, many-to-many categories to Weiro with admin CRUD, post assignment, static site archive pages, and per-category feeds.
|
||
|
|
|
||
|
|
**Architecture:** New `categories` and `post_categories` tables in SQLite. New sqlc queries, DB provider methods, a `categories` service, and a `CategoriesHandler`. The site builder gains category index/archive page rendering and per-category feeds. Posts carry category associations managed via a join table with delete-and-reinsert on save.
|
||
|
|
|
||
|
|
**Tech Stack:** Go 1.25, SQLite (sqlc), Fiber v3, Go html/template, Bootstrap 5, Stimulus.js
|
||
|
|
|
||
|
|
**Spec:** `docs/superpowers/specs/2026-03-18-categories-design.md`
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## File Map
|
||
|
|
|
||
|
|
| Action | File | Responsibility |
|
||
|
|
|--------|------|---------------|
|
||
|
|
| Create | `models/categories.go` | Category model + slug generation |
|
||
|
|
| Create | `sql/schema/04_categories.up.sql` | Migration: categories + post_categories tables |
|
||
|
|
| Create | `sql/queries/categories.sql` | All sqlc queries for categories |
|
||
|
|
| Create | `providers/db/categories.go` | DB provider wrapper methods for categories |
|
||
|
|
| Create | `services/categories/service.go` | Category service: CRUD + slug validation |
|
||
|
|
| Create | `handlers/categories.go` | HTTP handlers for category admin pages |
|
||
|
|
| Create | `views/categories/index.html` | Admin: category list page |
|
||
|
|
| Create | `views/categories/edit.html` | Admin: category create/edit form |
|
||
|
|
| Create | `layouts/simplecss/categories_list.html` | Published site: category index page template |
|
||
|
|
| Create | `layouts/simplecss/categories_single.html` | Published site: category archive page template |
|
||
|
|
| Modify | `models/errors.go` | Add `SlugConflictError` |
|
||
|
|
| Modify | `providers/db/gen/sqlgen/*` | Regenerated by sqlc |
|
||
|
|
| Modify | `providers/db/posts.go` | Add `SelectCategoriesOfPost`, `SetPostCategories` |
|
||
|
|
| Modify | `providers/db/provider.go` | Expose `drvr` for transactions via `BeginTx` |
|
||
|
|
| Modify | `models/pubmodel/sites.go` | Add `Categories`, `PostIterByCategory` fields |
|
||
|
|
| Modify | `providers/sitebuilder/tmpls.go` | Add category template names + data structs |
|
||
|
|
| Modify | `providers/sitebuilder/builder.go` | Render category pages + per-category feeds |
|
||
|
|
| Modify | `services/posts/service.go` | Accept DB transaction support |
|
||
|
|
| Modify | `services/posts/create.go` | Save category associations in transaction |
|
||
|
|
| Modify | `services/publisher/service.go` | Populate category data on `pubmodel.Site` |
|
||
|
|
| Modify | `services/publisher/iter.go` | Add `postIterByCategory` method |
|
||
|
|
| Modify | `services/services.go` | Wire up categories service |
|
||
|
|
| Modify | `cmds/server.go` | Register category routes + handler |
|
||
|
|
| Modify | `views/posts/edit.html` | Add category sidebar with checkboxes |
|
||
|
|
| Modify | `views/posts/index.html` | Show category badges on post list |
|
||
|
|
| Modify | `views/_common/nav.html` | Add "Categories" nav link |
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 1: Database Migration + Model
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `sql/schema/04_categories.up.sql`
|
||
|
|
- Create: `models/categories.go`
|
||
|
|
- Modify: `models/errors.go`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the migration file**
|
||
|
|
|
||
|
|
Create `sql/schema/04_categories.up.sql`:
|
||
|
|
|
||
|
|
```sql
|
||
|
|
CREATE TABLE categories (
|
||
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||
|
|
site_id INTEGER NOT NULL,
|
||
|
|
guid TEXT NOT NULL,
|
||
|
|
name TEXT NOT NULL,
|
||
|
|
slug TEXT NOT NULL,
|
||
|
|
description TEXT NOT NULL DEFAULT '',
|
||
|
|
created_at INTEGER NOT NULL,
|
||
|
|
updated_at INTEGER NOT NULL,
|
||
|
|
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
|
||
|
|
);
|
||
|
|
CREATE INDEX idx_categories_site ON categories (site_id);
|
||
|
|
CREATE UNIQUE INDEX idx_categories_guid ON categories (guid);
|
||
|
|
CREATE UNIQUE INDEX idx_categories_site_slug ON categories (site_id, slug);
|
||
|
|
|
||
|
|
CREATE TABLE post_categories (
|
||
|
|
post_id INTEGER NOT NULL,
|
||
|
|
category_id INTEGER NOT NULL,
|
||
|
|
PRIMARY KEY (post_id, category_id),
|
||
|
|
FOREIGN KEY (post_id) REFERENCES posts (id) ON DELETE CASCADE,
|
||
|
|
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||
|
|
);
|
||
|
|
CREATE INDEX idx_post_categories_category ON post_categories (category_id);
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the Category model**
|
||
|
|
|
||
|
|
Create `models/categories.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package models
|
||
|
|
|
||
|
|
import (
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
"unicode"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Category struct {
|
||
|
|
ID int64 `json:"id"`
|
||
|
|
SiteID int64 `json:"site_id"`
|
||
|
|
GUID string `json:"guid"`
|
||
|
|
Name string `json:"name"`
|
||
|
|
Slug string `json:"slug"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
CreatedAt time.Time `json:"created_at"`
|
||
|
|
UpdatedAt time.Time `json:"updated_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// CategoryWithCount is a Category plus the count of published posts in it.
|
||
|
|
type CategoryWithCount struct {
|
||
|
|
Category
|
||
|
|
PostCount int
|
||
|
|
DescriptionBrief string
|
||
|
|
}
|
||
|
|
|
||
|
|
// GenerateCategorySlug creates a URL-safe slug from a category name.
|
||
|
|
// e.g. "Go Programming" -> "go-programming"
|
||
|
|
func GenerateCategorySlug(name string) string {
|
||
|
|
var sb strings.Builder
|
||
|
|
prevDash := false
|
||
|
|
for _, c := range strings.TrimSpace(name) {
|
||
|
|
if unicode.IsLetter(c) || unicode.IsNumber(c) {
|
||
|
|
sb.WriteRune(unicode.ToLower(c))
|
||
|
|
prevDash = false
|
||
|
|
} else if unicode.IsSpace(c) || c == '-' || c == '_' {
|
||
|
|
if !prevDash && sb.Len() > 0 {
|
||
|
|
sb.WriteRune('-')
|
||
|
|
prevDash = true
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
result := sb.String()
|
||
|
|
return strings.TrimRight(result, "-")
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Add SlugConflictError to models/errors.go**
|
||
|
|
|
||
|
|
Add to `models/errors.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
var SlugConflictError = errors.New("a category with this slug already exists")
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Write a test for GenerateCategorySlug**
|
||
|
|
|
||
|
|
Create `models/categories_test.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package models_test
|
||
|
|
|
||
|
|
import (
|
||
|
|
"testing"
|
||
|
|
|
||
|
|
"github.com/stretchr/testify/assert"
|
||
|
|
"lmika.dev/lmika/weiro/models"
|
||
|
|
)
|
||
|
|
|
||
|
|
func TestGenerateCategorySlug(t *testing.T) {
|
||
|
|
tests := []struct {
|
||
|
|
name string
|
||
|
|
want string
|
||
|
|
}{
|
||
|
|
{"Go Programming", "go-programming"},
|
||
|
|
{" Travel ", "travel"},
|
||
|
|
{"hello---world", "hello-world"},
|
||
|
|
{"UPPER CASE", "upper-case"},
|
||
|
|
{"one", "one"},
|
||
|
|
{"with_underscores", "with-underscores"},
|
||
|
|
{"special!@#chars", "specialchars"},
|
||
|
|
}
|
||
|
|
for _, tt := range tests {
|
||
|
|
t.Run(tt.name, func(t *testing.T) {
|
||
|
|
assert.Equal(t, tt.want, models.GenerateCategorySlug(tt.name))
|
||
|
|
})
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run the test**
|
||
|
|
|
||
|
|
Run: `go test ./models/ -run TestGenerateCategorySlug -v`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add models/categories.go models/categories_test.go models/errors.go sql/schema/04_categories.up.sql
|
||
|
|
git commit -m "feat: add categories migration and model"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 2: SQL Queries + sqlc Generation
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `sql/queries/categories.sql`
|
||
|
|
- Regenerate: `providers/db/gen/sqlgen/*`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the sqlc queries file**
|
||
|
|
|
||
|
|
Create `sql/queries/categories.sql`:
|
||
|
|
|
||
|
|
```sql
|
||
|
|
-- name: SelectCategoriesOfSite :many
|
||
|
|
SELECT * FROM categories
|
||
|
|
WHERE site_id = ? ORDER BY name ASC;
|
||
|
|
|
||
|
|
-- name: SelectCategory :one
|
||
|
|
SELECT * FROM categories WHERE id = ? LIMIT 1;
|
||
|
|
|
||
|
|
-- name: SelectCategoryByGUID :one
|
||
|
|
SELECT * FROM categories WHERE guid = ? LIMIT 1;
|
||
|
|
|
||
|
|
-- name: SelectCategoryBySlugAndSite :one
|
||
|
|
SELECT * FROM categories WHERE site_id = ? AND slug = ? LIMIT 1;
|
||
|
|
|
||
|
|
-- name: SelectCategoriesOfPost :many
|
||
|
|
SELECT c.* FROM categories c
|
||
|
|
INNER JOIN post_categories pc ON pc.category_id = c.id
|
||
|
|
WHERE pc.post_id = ?
|
||
|
|
ORDER BY c.name ASC;
|
||
|
|
|
||
|
|
-- name: SelectPostsOfCategory :many
|
||
|
|
SELECT p.* FROM posts p
|
||
|
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||
|
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0
|
||
|
|
ORDER BY p.published_at DESC
|
||
|
|
LIMIT ? OFFSET ?;
|
||
|
|
|
||
|
|
-- name: CountPostsOfCategory :one
|
||
|
|
SELECT COUNT(*) FROM posts p
|
||
|
|
INNER JOIN post_categories pc ON pc.post_id = p.id
|
||
|
|
WHERE pc.category_id = ? AND p.state = 0 AND p.deleted_at = 0;
|
||
|
|
|
||
|
|
-- name: InsertCategory :one
|
||
|
|
INSERT INTO categories (
|
||
|
|
site_id, guid, name, slug, description, created_at, updated_at
|
||
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||
|
|
RETURNING id;
|
||
|
|
|
||
|
|
-- name: UpdateCategory :exec
|
||
|
|
UPDATE categories SET
|
||
|
|
name = ?,
|
||
|
|
slug = ?,
|
||
|
|
description = ?,
|
||
|
|
updated_at = ?
|
||
|
|
WHERE id = ?;
|
||
|
|
|
||
|
|
-- name: DeleteCategory :exec
|
||
|
|
DELETE FROM categories WHERE id = ?;
|
||
|
|
|
||
|
|
-- name: InsertPostCategory :exec
|
||
|
|
INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?);
|
||
|
|
|
||
|
|
-- name: DeletePostCategoriesByPost :exec
|
||
|
|
DELETE FROM post_categories WHERE post_id = ?;
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run sqlc generate**
|
||
|
|
|
||
|
|
Run: `sqlc generate`
|
||
|
|
Expected: No errors. New file `providers/db/gen/sqlgen/categories.sql.go` created and `models.go` updated with `Category` and `PostCategory` structs.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Verify the generated code compiles**
|
||
|
|
|
||
|
|
Run: `go build ./providers/db/gen/sqlgen/`
|
||
|
|
Expected: No errors.
|
||
|
|
|
||
|
|
- [ ] **Step 4: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add sql/queries/categories.sql providers/db/gen/sqlgen/
|
||
|
|
git commit -m "feat: add sqlc queries for categories"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 3: DB Provider — Category Methods
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `providers/db/categories.go`
|
||
|
|
- Modify: `providers/db/provider.go`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Write failing test for category CRUD**
|
||
|
|
|
||
|
|
Add to `providers/db/provider_test.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestProvider_Categories(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
p := newTestDB(t)
|
||
|
|
|
||
|
|
user := &models.User{Username: "testuser", PasswordHashed: []byte("password")}
|
||
|
|
require.NoError(t, p.SaveUser(ctx, user))
|
||
|
|
|
||
|
|
site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"}
|
||
|
|
require.NoError(t, p.SaveSite(ctx, site))
|
||
|
|
|
||
|
|
t.Run("save and select categories", func(t *testing.T) {
|
||
|
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||
|
|
cat := &models.Category{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: "cat-001",
|
||
|
|
Name: "Go Programming",
|
||
|
|
Slug: "go-programming",
|
||
|
|
Description: "Posts about Go",
|
||
|
|
CreatedAt: now,
|
||
|
|
UpdatedAt: now,
|
||
|
|
}
|
||
|
|
|
||
|
|
err := p.SaveCategory(ctx, cat)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.NotZero(t, cat.ID)
|
||
|
|
|
||
|
|
cats, err := p.SelectCategoriesOfSite(ctx, site.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, cats, 1)
|
||
|
|
assert.Equal(t, "Go Programming", cats[0].Name)
|
||
|
|
assert.Equal(t, "go-programming", cats[0].Slug)
|
||
|
|
assert.Equal(t, "Posts about Go", cats[0].Description)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("update category", func(t *testing.T) {
|
||
|
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||
|
|
cat := &models.Category{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: "cat-002",
|
||
|
|
Name: "Original",
|
||
|
|
Slug: "original",
|
||
|
|
CreatedAt: now,
|
||
|
|
UpdatedAt: now,
|
||
|
|
}
|
||
|
|
require.NoError(t, p.SaveCategory(ctx, cat))
|
||
|
|
|
||
|
|
cat.Name = "Updated"
|
||
|
|
cat.Slug = "updated"
|
||
|
|
cat.UpdatedAt = now.Add(time.Hour)
|
||
|
|
require.NoError(t, p.SaveCategory(ctx, cat))
|
||
|
|
|
||
|
|
got, err := p.SelectCategory(ctx, cat.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, "Updated", got.Name)
|
||
|
|
assert.Equal(t, "updated", got.Slug)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("delete category", func(t *testing.T) {
|
||
|
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||
|
|
cat := &models.Category{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: "cat-003",
|
||
|
|
Name: "ToDelete",
|
||
|
|
Slug: "to-delete",
|
||
|
|
CreatedAt: now,
|
||
|
|
UpdatedAt: now,
|
||
|
|
}
|
||
|
|
require.NoError(t, p.SaveCategory(ctx, cat))
|
||
|
|
|
||
|
|
err := p.DeleteCategory(ctx, cat.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
|
||
|
|
_, err = p.SelectCategory(ctx, cat.ID)
|
||
|
|
assert.Error(t, err)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestProvider_PostCategories(t *testing.T) {
|
||
|
|
ctx := context.Background()
|
||
|
|
p := newTestDB(t)
|
||
|
|
|
||
|
|
user := &models.User{Username: "testuser", PasswordHashed: []byte("password")}
|
||
|
|
require.NoError(t, p.SaveUser(ctx, user))
|
||
|
|
|
||
|
|
site := &models.Site{OwnerID: user.ID, Title: "My Blog", Tagline: "test"}
|
||
|
|
require.NoError(t, p.SaveSite(ctx, site))
|
||
|
|
|
||
|
|
now := time.Date(2026, 3, 18, 12, 0, 0, 0, time.UTC)
|
||
|
|
post := &models.Post{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: "post-pc-001",
|
||
|
|
Title: "Test Post",
|
||
|
|
Body: "body",
|
||
|
|
Slug: "/test",
|
||
|
|
CreatedAt: now,
|
||
|
|
}
|
||
|
|
require.NoError(t, p.SavePost(ctx, post))
|
||
|
|
|
||
|
|
cat1 := &models.Category{SiteID: site.ID, GUID: "cat-pc-1", Name: "Alpha", Slug: "alpha", CreatedAt: now, UpdatedAt: now}
|
||
|
|
cat2 := &models.Category{SiteID: site.ID, GUID: "cat-pc-2", Name: "Beta", Slug: "beta", CreatedAt: now, UpdatedAt: now}
|
||
|
|
require.NoError(t, p.SaveCategory(ctx, cat1))
|
||
|
|
require.NoError(t, p.SaveCategory(ctx, cat2))
|
||
|
|
|
||
|
|
t.Run("set and get post categories", func(t *testing.T) {
|
||
|
|
err := p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID})
|
||
|
|
require.NoError(t, err)
|
||
|
|
|
||
|
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, cats, 2)
|
||
|
|
assert.Equal(t, "Alpha", cats[0].Name)
|
||
|
|
assert.Equal(t, "Beta", cats[1].Name)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("replace post categories", func(t *testing.T) {
|
||
|
|
err := p.SetPostCategories(ctx, post.ID, []int64{cat2.ID})
|
||
|
|
require.NoError(t, err)
|
||
|
|
|
||
|
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, cats, 1)
|
||
|
|
assert.Equal(t, "Beta", cats[0].Name)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("clear post categories", func(t *testing.T) {
|
||
|
|
err := p.SetPostCategories(ctx, post.ID, []int64{})
|
||
|
|
require.NoError(t, err)
|
||
|
|
|
||
|
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Empty(t, cats)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("count posts of category", func(t *testing.T) {
|
||
|
|
// Publish the post (state=0)
|
||
|
|
post.State = models.StatePublished
|
||
|
|
post.PublishedAt = now
|
||
|
|
require.NoError(t, p.SavePost(ctx, post))
|
||
|
|
require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID}))
|
||
|
|
|
||
|
|
count, err := p.CountPostsOfCategory(ctx, cat1.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, int64(1), count)
|
||
|
|
|
||
|
|
count, err = p.CountPostsOfCategory(ctx, cat2.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
assert.Equal(t, int64(0), count)
|
||
|
|
})
|
||
|
|
|
||
|
|
t.Run("cascade delete category removes associations", func(t *testing.T) {
|
||
|
|
require.NoError(t, p.SetPostCategories(ctx, post.ID, []int64{cat1.ID, cat2.ID}))
|
||
|
|
require.NoError(t, p.DeleteCategory(ctx, cat1.ID))
|
||
|
|
|
||
|
|
cats, err := p.SelectCategoriesOfPost(ctx, post.ID)
|
||
|
|
require.NoError(t, err)
|
||
|
|
require.Len(t, cats, 1)
|
||
|
|
assert.Equal(t, "Beta", cats[0].Name)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
|
|
||
|
|
Run: `go test ./providers/db/ -run "TestProvider_Categories|TestProvider_PostCategories" -v`
|
||
|
|
Expected: FAIL — `SaveCategory`, `SelectCategoriesOfSite`, etc. not defined.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the DB provider category methods**
|
||
|
|
|
||
|
|
Create `providers/db/categories.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package db
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"lmika.dev/lmika/weiro/models"
|
||
|
|
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||
|
|
)
|
||
|
|
|
||
|
|
func (db *Provider) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]*models.Category, error) {
|
||
|
|
rows, err := db.queries.SelectCategoriesOfSite(ctx, siteID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
cats := make([]*models.Category, len(rows))
|
||
|
|
for i, row := range rows {
|
||
|
|
cats[i] = dbCategoryToCategory(row)
|
||
|
|
}
|
||
|
|
return cats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) SelectCategory(ctx context.Context, id int64) (*models.Category, error) {
|
||
|
|
row, err := db.queries.SelectCategory(ctx, id)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return dbCategoryToCategory(row), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) SelectCategoryBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Category, error) {
|
||
|
|
row, err := db.queries.SelectCategoryBySlugAndSite(ctx, sqlgen.SelectCategoryBySlugAndSiteParams{
|
||
|
|
SiteID: siteID,
|
||
|
|
Slug: slug,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return dbCategoryToCategory(row), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) SaveCategory(ctx context.Context, cat *models.Category) error {
|
||
|
|
if cat.ID == 0 {
|
||
|
|
newID, err := db.queries.InsertCategory(ctx, sqlgen.InsertCategoryParams{
|
||
|
|
SiteID: cat.SiteID,
|
||
|
|
Guid: cat.GUID,
|
||
|
|
Name: cat.Name,
|
||
|
|
Slug: cat.Slug,
|
||
|
|
Description: cat.Description,
|
||
|
|
CreatedAt: timeToInt(cat.CreatedAt),
|
||
|
|
UpdatedAt: timeToInt(cat.UpdatedAt),
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
cat.ID = newID
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
return db.queries.UpdateCategory(ctx, sqlgen.UpdateCategoryParams{
|
||
|
|
ID: cat.ID,
|
||
|
|
Name: cat.Name,
|
||
|
|
Slug: cat.Slug,
|
||
|
|
Description: cat.Description,
|
||
|
|
UpdatedAt: timeToInt(cat.UpdatedAt),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) DeleteCategory(ctx context.Context, id int64) error {
|
||
|
|
return db.queries.DeleteCategory(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||
|
|
rows, err := db.queries.SelectCategoriesOfPost(ctx, postID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
cats := make([]*models.Category, len(rows))
|
||
|
|
for i, row := range rows {
|
||
|
|
cats[i] = dbCategoryToCategory(row)
|
||
|
|
}
|
||
|
|
return cats, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) {
|
||
|
|
rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{
|
||
|
|
CategoryID: categoryID,
|
||
|
|
Limit: pp.Limit,
|
||
|
|
Offset: pp.Offset,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
posts := make([]*models.Post, len(rows))
|
||
|
|
for i, row := range rows {
|
||
|
|
posts[i] = dbPostToPost(row)
|
||
|
|
}
|
||
|
|
return posts, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) {
|
||
|
|
return db.queries.CountPostsOfCategory(ctx, categoryID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// SetPostCategories replaces all category associations for a post.
|
||
|
|
// It deletes existing associations and inserts the new ones.
|
||
|
|
func (db *Provider) SetPostCategories(ctx context.Context, postID int64, categoryIDs []int64) error {
|
||
|
|
if err := db.queries.DeletePostCategoriesByPost(ctx, postID); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
for _, catID := range categoryIDs {
|
||
|
|
if err := db.queries.InsertPostCategory(ctx, sqlgen.InsertPostCategoryParams{
|
||
|
|
PostID: postID,
|
||
|
|
CategoryID: catID,
|
||
|
|
}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func dbCategoryToCategory(row sqlgen.Category) *models.Category {
|
||
|
|
return &models.Category{
|
||
|
|
ID: row.ID,
|
||
|
|
SiteID: row.SiteID,
|
||
|
|
GUID: row.Guid,
|
||
|
|
Name: row.Name,
|
||
|
|
Slug: row.Slug,
|
||
|
|
Description: row.Description,
|
||
|
|
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
|
||
|
|
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Add BeginTx to provider for future transaction support**
|
||
|
|
|
||
|
|
Add to `providers/db/provider.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
import "database/sql"
|
||
|
|
|
||
|
|
func (db *Provider) BeginTx(ctx context.Context) (*sql.Tx, error) {
|
||
|
|
return db.drvr.BeginTx(ctx, nil)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (db *Provider) QueriesWithTx(tx *sql.Tx) *Provider {
|
||
|
|
return &Provider{
|
||
|
|
drvr: db.drvr,
|
||
|
|
queries: db.queries.WithTx(tx),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Run the tests**
|
||
|
|
|
||
|
|
Run: `go test ./providers/db/ -run "TestProvider_Categories|TestProvider_PostCategories" -v`
|
||
|
|
Expected: PASS
|
||
|
|
|
||
|
|
- [ ] **Step 6: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add providers/db/categories.go providers/db/provider.go providers/db/provider_test.go
|
||
|
|
git commit -m "feat: add DB provider methods for categories"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 4: Categories Service
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `services/categories/service.go`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Create the categories service**
|
||
|
|
|
||
|
|
Create `services/categories/service.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package categories
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"lmika.dev/lmika/weiro/models"
|
||
|
|
"lmika.dev/lmika/weiro/providers/db"
|
||
|
|
"lmika.dev/lmika/weiro/services/publisher"
|
||
|
|
)
|
||
|
|
|
||
|
|
type CreateCategoryParams struct {
|
||
|
|
GUID string `form:"guid" json:"guid"`
|
||
|
|
Name string `form:"name" json:"name"`
|
||
|
|
Slug string `form:"slug" json:"slug"`
|
||
|
|
Description string `form:"description" json:"description"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type Service struct {
|
||
|
|
db *db.Provider
|
||
|
|
publisher *publisher.Queue
|
||
|
|
}
|
||
|
|
|
||
|
|
func New(db *db.Provider, publisher *publisher.Queue) *Service {
|
||
|
|
return &Service{db: db, publisher: publisher}
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) ListCategories(ctx context.Context) ([]*models.Category, error) {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return nil, models.SiteRequiredError
|
||
|
|
}
|
||
|
|
return s.db.SelectCategoriesOfSite(ctx, site.ID)
|
||
|
|
}
|
||
|
|
|
||
|
|
// ListCategoriesWithCounts returns all categories for the site with published post counts.
|
||
|
|
func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.CategoryWithCount, error) {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return nil, models.SiteRequiredError
|
||
|
|
}
|
||
|
|
|
||
|
|
cats, err := s.db.SelectCategoriesOfSite(ctx, site.ID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
result := make([]models.CategoryWithCount, len(cats))
|
||
|
|
for i, cat := range cats {
|
||
|
|
count, err := s.db.CountPostsOfCategory(ctx, cat.ID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
result[i] = models.CategoryWithCount{
|
||
|
|
Category: *cat,
|
||
|
|
PostCount: int(count),
|
||
|
|
DescriptionBrief: briefDescription(cat.Description),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) {
|
||
|
|
return s.db.SelectCategory(ctx, id)
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return nil, models.SiteRequiredError
|
||
|
|
}
|
||
|
|
|
||
|
|
now := time.Now()
|
||
|
|
slug := params.Slug
|
||
|
|
if slug == "" {
|
||
|
|
slug = models.GenerateCategorySlug(params.Name)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check for slug collision
|
||
|
|
if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil {
|
||
|
|
return nil, models.SlugConflictError
|
||
|
|
}
|
||
|
|
|
||
|
|
cat := &models.Category{
|
||
|
|
SiteID: site.ID,
|
||
|
|
GUID: params.GUID,
|
||
|
|
Name: params.Name,
|
||
|
|
Slug: slug,
|
||
|
|
Description: params.Description,
|
||
|
|
CreatedAt: now,
|
||
|
|
UpdatedAt: now,
|
||
|
|
}
|
||
|
|
if cat.GUID == "" {
|
||
|
|
cat.GUID = models.NewNanoID()
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.db.SaveCategory(ctx, cat); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.publisher.Queue(site)
|
||
|
|
return cat, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCategoryParams) (*models.Category, error) {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return nil, models.SiteRequiredError
|
||
|
|
}
|
||
|
|
|
||
|
|
cat, err := s.db.SelectCategory(ctx, id)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if cat.SiteID != site.ID {
|
||
|
|
return nil, models.NotFoundError
|
||
|
|
}
|
||
|
|
|
||
|
|
slug := params.Slug
|
||
|
|
if slug == "" {
|
||
|
|
slug = models.GenerateCategorySlug(params.Name)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Check slug collision (exclude self)
|
||
|
|
if existing, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != cat.ID {
|
||
|
|
return nil, models.SlugConflictError
|
||
|
|
}
|
||
|
|
|
||
|
|
cat.Name = params.Name
|
||
|
|
cat.Slug = slug
|
||
|
|
cat.Description = params.Description
|
||
|
|
cat.UpdatedAt = time.Now()
|
||
|
|
|
||
|
|
if err := s.db.SaveCategory(ctx, cat); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.publisher.Queue(site)
|
||
|
|
return cat, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (s *Service) DeleteCategory(ctx context.Context, id int64) error {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return models.SiteRequiredError
|
||
|
|
}
|
||
|
|
|
||
|
|
cat, err := s.db.SelectCategory(ctx, id)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
if cat.SiteID != site.ID {
|
||
|
|
return models.NotFoundError
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := s.db.DeleteCategory(ctx, id); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
s.publisher.Queue(site)
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// briefDescription returns the first sentence or line of the description.
|
||
|
|
func briefDescription(desc string) string {
|
||
|
|
if desc == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
// Find first period followed by space, or first newline
|
||
|
|
for i, c := range desc {
|
||
|
|
if c == '\n' {
|
||
|
|
return desc[:i]
|
||
|
|
}
|
||
|
|
if c == '.' && i+1 < len(desc) {
|
||
|
|
return desc[:i+1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return desc
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Verify it compiles**
|
||
|
|
|
||
|
|
Run: `go build ./services/categories/`
|
||
|
|
Expected: No errors.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add services/categories/service.go
|
||
|
|
git commit -m "feat: add categories service with CRUD and slug validation"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 5: Wire Up Service + Categories Handler + Admin Routes
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Create: `handlers/categories.go`
|
||
|
|
- Create: `views/categories/index.html`
|
||
|
|
- Create: `views/categories/edit.html`
|
||
|
|
- Modify: `services/services.go`
|
||
|
|
- Modify: `cmds/server.go`
|
||
|
|
- Modify: `views/_common/nav.html`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Wire up categories service in services.go**
|
||
|
|
|
||
|
|
Modify `services/services.go` — add to the `Services` struct:
|
||
|
|
|
||
|
|
```go
|
||
|
|
Categories *categories.Service
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to the `New` function (after `uploadService`):
|
||
|
|
|
||
|
|
```go
|
||
|
|
categoriesService := categories.New(dbp, publisherQueue)
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to the return struct:
|
||
|
|
|
||
|
|
```go
|
||
|
|
Categories: categoriesService,
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the import:
|
||
|
|
|
||
|
|
```go
|
||
|
|
"lmika.dev/lmika/weiro/services/categories"
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Create the categories handler**
|
||
|
|
|
||
|
|
Create `handlers/categories.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package handlers
|
||
|
|
|
||
|
|
import (
|
||
|
|
"fmt"
|
||
|
|
"strconv"
|
||
|
|
|
||
|
|
"github.com/gofiber/fiber/v3"
|
||
|
|
"lmika.dev/lmika/weiro/models"
|
||
|
|
"lmika.dev/lmika/weiro/services/categories"
|
||
|
|
)
|
||
|
|
|
||
|
|
type CategoriesHandler struct {
|
||
|
|
CategoryService *categories.Service
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ch CategoriesHandler) Index(c fiber.Ctx) error {
|
||
|
|
cats, err := ch.CategoryService.ListCategoriesWithCounts(c.Context())
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.Render("categories/index", fiber.Map{
|
||
|
|
"categories": cats,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ch CategoriesHandler) New(c fiber.Ctx) error {
|
||
|
|
cat := models.Category{
|
||
|
|
GUID: models.NewNanoID(),
|
||
|
|
}
|
||
|
|
return c.Render("categories/edit", fiber.Map{
|
||
|
|
"category": cat,
|
||
|
|
"isNew": true,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ch CategoriesHandler) Edit(c fiber.Ctx) error {
|
||
|
|
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return fiber.ErrBadRequest
|
||
|
|
}
|
||
|
|
|
||
|
|
cat, err := ch.CategoryService.GetCategory(c.Context(), catID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.Render("categories/edit", fiber.Map{
|
||
|
|
"category": cat,
|
||
|
|
"isNew": false,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ch CategoriesHandler) Create(c fiber.Ctx) error {
|
||
|
|
var req categories.CreateCategoryParams
|
||
|
|
if err := c.Bind().Body(&req); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err := ch.CategoryService.CreateCategory(c.Context(), req)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
site := models.MustGetSite(c.Context())
|
||
|
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ch CategoriesHandler) Update(c fiber.Ctx) error {
|
||
|
|
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return fiber.ErrBadRequest
|
||
|
|
}
|
||
|
|
|
||
|
|
var req categories.CreateCategoryParams
|
||
|
|
if err := c.Bind().Body(&req); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
_, err = ch.CategoryService.UpdateCategory(c.Context(), catID, req)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
site := models.MustGetSite(c.Context())
|
||
|
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
|
||
|
|
}
|
||
|
|
|
||
|
|
func (ch CategoriesHandler) Delete(c fiber.Ctx) error {
|
||
|
|
catID, err := strconv.ParseInt(c.Params("categoryID"), 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return fiber.ErrBadRequest
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := ch.CategoryService.DeleteCategory(c.Context(), catID); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
site := models.MustGetSite(c.Context())
|
||
|
|
return c.Redirect().To(fmt.Sprintf("/sites/%v/categories", site.ID))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the category admin templates**
|
||
|
|
|
||
|
|
Create `views/categories/index.html`:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<main class="container">
|
||
|
|
<div class="my-4 d-flex justify-content-between align-items-baseline">
|
||
|
|
<h4>Categories</h4>
|
||
|
|
<div>
|
||
|
|
<a href="/sites/{{ .site.ID }}/categories/new" class="btn btn-success">New Category</a>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<table class="table">
|
||
|
|
<thead>
|
||
|
|
<tr>
|
||
|
|
<th>Name</th>
|
||
|
|
<th>Slug</th>
|
||
|
|
<th>Posts</th>
|
||
|
|
<th></th>
|
||
|
|
</tr>
|
||
|
|
</thead>
|
||
|
|
<tbody>
|
||
|
|
{{ range .categories }}
|
||
|
|
<tr>
|
||
|
|
<td><a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}">{{ .Name }}</a></td>
|
||
|
|
<td><code>{{ .Slug }}</code></td>
|
||
|
|
<td>{{ .PostCount }}</td>
|
||
|
|
<td>
|
||
|
|
<a href="/sites/{{ $.site.ID }}/categories/{{ .ID }}" class="btn btn-outline-secondary btn-sm">Edit</a>
|
||
|
|
</td>
|
||
|
|
</tr>
|
||
|
|
{{ else }}
|
||
|
|
<tr>
|
||
|
|
<td colspan="4" class="text-center text-muted py-4">No categories yet.</td>
|
||
|
|
</tr>
|
||
|
|
{{ end }}
|
||
|
|
</tbody>
|
||
|
|
</table>
|
||
|
|
</main>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `views/categories/edit.html`:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<main class="container">
|
||
|
|
<div class="my-4">
|
||
|
|
<h4>{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}</h4>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{{ if .isNew }}
|
||
|
|
<form method="post" action="/sites/{{ .site.ID }}/categories">
|
||
|
|
{{ else }}
|
||
|
|
<form method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}">
|
||
|
|
{{ end }}
|
||
|
|
<input type="hidden" name="guid" value="{{ .category.GUID }}">
|
||
|
|
<div class="row mb-3">
|
||
|
|
<label for="catName" class="col-sm-2 col-form-label">Name</label>
|
||
|
|
<div class="col-sm-6">
|
||
|
|
<input type="text" class="form-control" id="catName" name="name" value="{{ .category.Name }}">
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="row mb-3">
|
||
|
|
<label for="catSlug" class="col-sm-2 col-form-label">Slug</label>
|
||
|
|
<div class="col-sm-6">
|
||
|
|
<input type="text" class="form-control" id="catSlug" name="slug" value="{{ .category.Slug }}">
|
||
|
|
<div class="form-text">Auto-generated from name if left blank.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="row mb-3">
|
||
|
|
<label for="catDesc" class="col-sm-2 col-form-label">Description</label>
|
||
|
|
<div class="col-sm-9">
|
||
|
|
<textarea class="form-control" id="catDesc" name="description" rows="5">{{ .category.Description }}</textarea>
|
||
|
|
<div class="form-text">Markdown supported. Displayed on the category archive page.</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="row mb-3">
|
||
|
|
<div class="col-sm-2"></div>
|
||
|
|
<div class="col-sm-9">
|
||
|
|
<button type="submit" class="btn btn-primary">{{ if .isNew }}Create{{ else }}Save{{ end }}</button>
|
||
|
|
{{ if not .isNew }}
|
||
|
|
<button type="button" class="btn btn-outline-danger ms-2"
|
||
|
|
onclick="if(confirm('Delete this category? Posts will not be deleted.')) { document.getElementById('delete-form').submit(); }">Delete</button>
|
||
|
|
{{ end }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
|
||
|
|
{{ if not .isNew }}
|
||
|
|
<form id="delete-form" method="post" action="/sites/{{ .site.ID }}/categories/{{ .category.ID }}/delete" style="display:none;"></form>
|
||
|
|
{{ end }}
|
||
|
|
</main>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Register routes in server.go**
|
||
|
|
|
||
|
|
Add to `cmds/server.go` after the `ssh` handler initialization:
|
||
|
|
|
||
|
|
```go
|
||
|
|
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add routes in the `siteGroup` block (after the uploads routes):
|
||
|
|
|
||
|
|
```go
|
||
|
|
siteGroup.Get("/categories", ch.Index)
|
||
|
|
siteGroup.Get("/categories/new", ch.New)
|
||
|
|
siteGroup.Get("/categories/:categoryID", ch.Edit)
|
||
|
|
siteGroup.Post("/categories", ch.Create)
|
||
|
|
siteGroup.Post("/categories/:categoryID", ch.Update)
|
||
|
|
siteGroup.Post("/categories/:categoryID/delete", ch.Delete)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Add "Categories" link to admin nav**
|
||
|
|
|
||
|
|
Modify `views/_common/nav.html` — add after the Posts nav item:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<li class="nav-item">
|
||
|
|
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
|
||
|
|
</li>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Verify the app compiles**
|
||
|
|
|
||
|
|
Run: `go build ./...`
|
||
|
|
Expected: No errors (ignoring existing build issues in sitereader).
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add handlers/categories.go views/categories/ views/_common/nav.html services/services.go cmds/server.go
|
||
|
|
git commit -m "feat: add categories admin UI with CRUD"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 6: Post Edit Form — Category Sidebar
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `views/posts/edit.html`
|
||
|
|
- Modify: `handlers/posts.go`
|
||
|
|
- Modify: `services/posts/create.go`
|
||
|
|
- Modify: `services/posts/list.go`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Pass categories to the post edit handler**
|
||
|
|
|
||
|
|
Modify `handlers/posts.go` — add `CategoryService` field to `PostsHandler`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
type PostsHandler struct {
|
||
|
|
PostService *posts.Service
|
||
|
|
CategoryService *categories.Service
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the import for `"lmika.dev/lmika/weiro/services/categories"`.
|
||
|
|
|
||
|
|
In the `New` method, fetch categories and pass them along with selected IDs (empty for new post):
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (ph PostsHandler) New(c fiber.Ctx) error {
|
||
|
|
p := models.Post{
|
||
|
|
GUID: models.NewNanoID(),
|
||
|
|
State: models.StateDraft,
|
||
|
|
}
|
||
|
|
|
||
|
|
cats, err := ph.CategoryService.ListCategories(c.Context())
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return c.Render("posts/edit", fiber.Map{
|
||
|
|
"post": p,
|
||
|
|
"categories": cats,
|
||
|
|
"selectedCategories": map[int64]bool{},
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
In the `Edit` method, fetch categories and the post's current categories:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (ph PostsHandler) Edit(c fiber.Ctx) error {
|
||
|
|
postIDStr := c.Params("postID")
|
||
|
|
if postIDStr == "" {
|
||
|
|
return fiber.ErrBadRequest
|
||
|
|
}
|
||
|
|
postID, err := strconv.ParseInt(postIDStr, 10, 64)
|
||
|
|
if err != nil {
|
||
|
|
return fiber.ErrBadRequest
|
||
|
|
}
|
||
|
|
|
||
|
|
post, err := ph.PostService.GetPost(c.Context(), postID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
cats, err := ph.CategoryService.ListCategories(c.Context())
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
postCats, err := ph.PostService.GetPostCategories(c.Context(), postID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
selectedCategories := make(map[int64]bool)
|
||
|
|
for _, pc := range postCats {
|
||
|
|
selectedCategories[pc.ID] = true
|
||
|
|
}
|
||
|
|
|
||
|
|
return accepts(c, json(func() any {
|
||
|
|
return post
|
||
|
|
}), html(func(c fiber.Ctx) error {
|
||
|
|
return c.Render("posts/edit", fiber.Map{
|
||
|
|
"post": post,
|
||
|
|
"categories": cats,
|
||
|
|
"selectedCategories": selectedCategories,
|
||
|
|
})
|
||
|
|
}))
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add CategoryIDs to CreatePostParams and update service**
|
||
|
|
|
||
|
|
Modify `services/posts/create.go` — add to `CreatePostParams`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
CategoryIDs []int64 `form:"category_ids" json:"category_ids"`
|
||
|
|
```
|
||
|
|
|
||
|
|
Add `GetPostCategories` method to `services/posts/list.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||
|
|
return s.db.SelectCategoriesOfPost(ctx, postID)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Wrap the post save and category assignment in a transaction. Replace the `s.db.SavePost(ctx, post)` call and add category handling:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Use a transaction for atomicity of post save + category reassignment
|
||
|
|
tx, err := s.db.BeginTx(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer tx.Rollback()
|
||
|
|
|
||
|
|
txDB := s.db.QueriesWithTx(tx)
|
||
|
|
if err := txDB.SavePost(ctx, post); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := txDB.SetPostCategories(ctx, post.ID, params.CategoryIDs); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
if err := tx.Commit(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
This replaces the existing non-transactional `s.db.SavePost(ctx, post)` call.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Wire CategoryService into PostsHandler in server.go**
|
||
|
|
|
||
|
|
Modify the `ph` initialization in `cmds/server.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update the post edit template with category sidebar**
|
||
|
|
|
||
|
|
Replace the content of `views/posts/edit.html` with:
|
||
|
|
|
||
|
|
```html
|
||
|
|
{{ $isPublished := ne .post.State 1 }}
|
||
|
|
<main class="flex-grow-1 position-relative">
|
||
|
|
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"
|
||
|
|
data-controller="postedit"
|
||
|
|
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
|
||
|
|
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
|
||
|
|
<div class="row">
|
||
|
|
<div class="col-md-9">
|
||
|
|
<input type="hidden" name="guid" value="{{ .post.GUID }}">
|
||
|
|
<div class="mb-2">
|
||
|
|
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
<textarea data-postedit-target="bodyTextEdit" name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
{{ if $isPublished }}
|
||
|
|
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
|
||
|
|
{{ else }}
|
||
|
|
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
|
||
|
|
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
|
||
|
|
{{ end }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
<div class="col-md-3">
|
||
|
|
<div class="card">
|
||
|
|
<div class="card-header">Categories</div>
|
||
|
|
<div class="card-body">
|
||
|
|
{{ range .categories }}
|
||
|
|
<div class="form-check">
|
||
|
|
<input class="form-check-input" type="checkbox" name="category_ids"
|
||
|
|
value="{{ .ID }}" id="cat-{{ .ID }}"
|
||
|
|
{{ if index $.selectedCategories .ID }}checked{{ end }}>
|
||
|
|
<label class="form-check-label" for="cat-{{ .ID }}">{{ .Name }}</label>
|
||
|
|
</div>
|
||
|
|
{{ else }}
|
||
|
|
<span class="text-muted">No categories yet.</span>
|
||
|
|
{{ end }}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
</form>
|
||
|
|
</main>
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Show category badges on post list**
|
||
|
|
|
||
|
|
Modify `services/posts/list.go` — update `ListPosts` to return posts with categories. Add a new type:
|
||
|
|
|
||
|
|
```go
|
||
|
|
type PostWithCategories struct {
|
||
|
|
*models.Post
|
||
|
|
Categories []*models.Category
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `ListPosts` to return `[]*PostWithCategories`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) {
|
||
|
|
site, ok := models.GetSite(ctx)
|
||
|
|
if !ok {
|
||
|
|
return nil, models.SiteRequiredError
|
||
|
|
}
|
||
|
|
|
||
|
|
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{
|
||
|
|
Offset: 0,
|
||
|
|
Limit: 25,
|
||
|
|
})
|
||
|
|
if err != nil {
|
||
|
|
return nil, 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
|
||
|
|
}
|
||
|
|
result[i] = &PostWithCategories{Post: post, Categories: cats}
|
||
|
|
}
|
||
|
|
return result, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `views/posts/index.html` — after the Draft badge or date line (inside the `.mb-3.d-flex` div), add category badges. Replace the date/badge div:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<div class="mb-3 d-flex align-items-center flex-wrap gap-1">
|
||
|
|
{{ if eq .State 1 }}
|
||
|
|
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
|
||
|
|
{{ else }}
|
||
|
|
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
|
||
|
|
{{ end }}
|
||
|
|
{{ range .Categories }}
|
||
|
|
<span class="ms-1 badge bg-secondary-subtle text-secondary-emphasis border border-secondary-subtle">{{ .Name }}</span>
|
||
|
|
{{ end }}
|
||
|
|
</div>
|
||
|
|
```
|
||
|
|
|
||
|
|
Update the handler `Index` method in `handlers/posts.go` — the template variable `posts` stays the same but each item now has a `.Categories` field.
|
||
|
|
|
||
|
|
- [ ] **Step 6: Verify the app compiles**
|
||
|
|
|
||
|
|
Run: `go build ./...`
|
||
|
|
Expected: No errors.
|
||
|
|
|
||
|
|
- [ ] **Step 7: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add handlers/posts.go services/posts/create.go services/posts/list.go views/posts/edit.html views/posts/index.html cmds/server.go
|
||
|
|
git commit -m "feat: add category selection to post edit form and badges to post list"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 7: Site Builder — Category Pages + Feeds
|
||
|
|
|
||
|
|
**Files:**
|
||
|
|
- Modify: `models/pubmodel/sites.go`
|
||
|
|
- Modify: `providers/sitebuilder/tmpls.go`
|
||
|
|
- Modify: `providers/sitebuilder/builder.go`
|
||
|
|
- Create: `layouts/simplecss/categories_list.html`
|
||
|
|
- Create: `layouts/simplecss/categories_single.html`
|
||
|
|
- Modify: `services/publisher/service.go`
|
||
|
|
- Modify: `services/publisher/iter.go`
|
||
|
|
|
||
|
|
- [ ] **Step 1: Extend pubmodel.Site**
|
||
|
|
|
||
|
|
Modify `models/pubmodel/sites.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
package pubmodel
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"io"
|
||
|
|
"iter"
|
||
|
|
|
||
|
|
"lmika.dev/lmika/weiro/models"
|
||
|
|
)
|
||
|
|
|
||
|
|
type Site struct {
|
||
|
|
models.Site
|
||
|
|
BaseURL string
|
||
|
|
Uploads []models.Upload
|
||
|
|
|
||
|
|
OpenUpload func(u models.Upload) (io.ReadCloser, error)
|
||
|
|
PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]]
|
||
|
|
Categories []models.CategoryWithCount
|
||
|
|
PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]
|
||
|
|
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 2: Add template data structs and template names**
|
||
|
|
|
||
|
|
Add to `providers/sitebuilder/tmpls.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
const (
|
||
|
|
tmplNameCategoryList = "categories_list.html"
|
||
|
|
tmplNameCategorySingle = "categories_single.html"
|
||
|
|
)
|
||
|
|
|
||
|
|
type categoryListData struct {
|
||
|
|
commonData
|
||
|
|
Categories []categoryListItem
|
||
|
|
}
|
||
|
|
|
||
|
|
type categoryListItem struct {
|
||
|
|
models.CategoryWithCount
|
||
|
|
Path string
|
||
|
|
}
|
||
|
|
|
||
|
|
type categorySingleData struct {
|
||
|
|
commonData
|
||
|
|
Category *models.Category
|
||
|
|
DescriptionHTML template.HTML
|
||
|
|
Posts []postSingleData
|
||
|
|
Path string
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add to the `postSingleData` struct:
|
||
|
|
|
||
|
|
```go
|
||
|
|
Categories []*models.Category
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the import for `"lmika.dev/lmika/weiro/models"` if not already present.
|
||
|
|
|
||
|
|
- [ ] **Step 3: Create the published site category templates**
|
||
|
|
|
||
|
|
Create `layouts/simplecss/categories_list.html`:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<h2>Categories</h2>
|
||
|
|
<ul>
|
||
|
|
{{ range .Categories }}
|
||
|
|
<li>
|
||
|
|
<a href="{{ url_abs .Path }}">{{ .Name }}</a> ({{ .PostCount }})
|
||
|
|
{{ if .DescriptionBrief }}<br><small>{{ .DescriptionBrief }}</small>{{ end }}
|
||
|
|
</li>
|
||
|
|
{{ end }}
|
||
|
|
</ul>
|
||
|
|
```
|
||
|
|
|
||
|
|
Create `layouts/simplecss/categories_single.html`:
|
||
|
|
|
||
|
|
```html
|
||
|
|
<h2>{{ .Category.Name }}</h2>
|
||
|
|
{{ if .DescriptionHTML }}
|
||
|
|
<div>{{ .DescriptionHTML }}</div>
|
||
|
|
{{ end }}
|
||
|
|
{{ range .Posts }}
|
||
|
|
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||
|
|
{{ .HTML }}
|
||
|
|
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
||
|
|
{{ if .Categories }}
|
||
|
|
<p>
|
||
|
|
{{ range .Categories }}
|
||
|
|
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
|
||
|
|
{{ end }}
|
||
|
|
</p>
|
||
|
|
{{ end }}
|
||
|
|
{{ end }}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 4: Update the post single template to show categories**
|
||
|
|
|
||
|
|
Modify `layouts/simplecss/posts_single.html`:
|
||
|
|
|
||
|
|
```html
|
||
|
|
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||
|
|
{{ .HTML }}
|
||
|
|
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
||
|
|
{{ if .Categories }}
|
||
|
|
<p>
|
||
|
|
{{ range .Categories }}
|
||
|
|
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
|
||
|
|
{{ end }}
|
||
|
|
</p>
|
||
|
|
{{ end }}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 5: Update the post list template to show categories**
|
||
|
|
|
||
|
|
Modify `layouts/simplecss/posts_list.html`:
|
||
|
|
|
||
|
|
```html
|
||
|
|
{{ range .Posts }}
|
||
|
|
{{ if .Post.Title }}<h3>{{ .Post.Title }}</h3>{{ end }}
|
||
|
|
{{ .HTML }}
|
||
|
|
<a href="{{ url_abs .Path }}">{{ format_date .Post.PublishedAt }}</a>
|
||
|
|
{{ if .Categories }}
|
||
|
|
<p>
|
||
|
|
{{ range .Categories }}
|
||
|
|
<a href="{{ url_abs (printf "/categories/%s" .Slug) }}">{{ .Name }}</a>
|
||
|
|
{{ end }}
|
||
|
|
</p>
|
||
|
|
{{ end }}
|
||
|
|
{{ end }}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 6: Register new templates in builder.go**
|
||
|
|
|
||
|
|
Modify the `ParseFS` call in `sitebuilder.New()`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
tmpls, err := template.New("").
|
||
|
|
Funcs(templateFns(site, opts)).
|
||
|
|
ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle)
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 7: Add category rendering methods to builder.go**
|
||
|
|
|
||
|
|
Add the following methods to `providers/sitebuilder/builder.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (b *Builder) renderCategoryList(ctx buildContext) error {
|
||
|
|
var items []categoryListItem
|
||
|
|
for _, cwc := range b.site.Categories {
|
||
|
|
if cwc.PostCount == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
items = append(items, categoryListItem{
|
||
|
|
CategoryWithCount: cwc,
|
||
|
|
Path: fmt.Sprintf("/categories/%s", cwc.Slug),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
if len(items) == 0 {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
data := categoryListData{
|
||
|
|
commonData: commonData{Site: b.site},
|
||
|
|
Categories: items,
|
||
|
|
}
|
||
|
|
|
||
|
|
return b.createAtPath(ctx, "/categories", func(f io.Writer) error {
|
||
|
|
return b.renderTemplate(f, tmplNameCategoryList, data)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error {
|
||
|
|
for _, cwc := range b.site.Categories {
|
||
|
|
if cwc.PostCount == 0 {
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
|
||
|
|
var posts []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
|
||
|
|
}
|
||
|
|
posts = append(posts, rp)
|
||
|
|
}
|
||
|
|
|
||
|
|
var descHTML bytes.Buffer
|
||
|
|
if cwc.Description != "" {
|
||
|
|
if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
data := categorySingleData{
|
||
|
|
commonData: commonData{Site: b.site},
|
||
|
|
Category: &cwc.Category,
|
||
|
|
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 {
|
||
|
|
return b.renderTemplate(f, tmplNameCategorySingle, data)
|
||
|
|
}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Per-category feeds
|
||
|
|
if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *Builder) renderCategoryFeed(ctx buildContext, cwc models.CategoryWithCount, posts []postSingleData) error {
|
||
|
|
now := time.Now()
|
||
|
|
feed := &feedhub.Feed{
|
||
|
|
Title: b.site.Title + " - " + cwc.Name,
|
||
|
|
Link: &feedhub.Link{Href: b.site.BaseURL},
|
||
|
|
Description: cwc.DescriptionBrief,
|
||
|
|
Created: now,
|
||
|
|
}
|
||
|
|
|
||
|
|
for i, rp := range posts {
|
||
|
|
if i >= b.opts.FeedItems {
|
||
|
|
break
|
||
|
|
}
|
||
|
|
feed.Items = append(feed.Items, &feedhub.Item{
|
||
|
|
Id: filepath.Join(b.site.BaseURL, rp.Post.GUID),
|
||
|
|
Title: rp.Post.Title,
|
||
|
|
Link: &feedhub.Link{Href: rp.PostURL},
|
||
|
|
Content: string(rp.HTML),
|
||
|
|
Created: rp.Post.PublishedAt,
|
||
|
|
Updated: rp.Post.UpdatedAt,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
prefix := fmt.Sprintf("/categories/%s/feed", cwc.Slug)
|
||
|
|
|
||
|
|
if err := b.createAtPath(ctx, prefix+".xml", func(f io.Writer) error {
|
||
|
|
rss, err := feed.ToRss()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err = io.WriteString(f, rss)
|
||
|
|
return err
|
||
|
|
}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
return b.createAtPath(ctx, prefix+".json", func(f io.Writer) error {
|
||
|
|
j, err := feed.ToJSON()
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
_, err = io.WriteString(f, j)
|
||
|
|
return err
|
||
|
|
})
|
||
|
|
}
|
||
|
|
|
||
|
|
// renderPostWithCategories renders a post and attaches its categories.
|
||
|
|
func (b *Builder) renderPostWithCategories(ctx context.Context, post *models.Post) (postSingleData, error) {
|
||
|
|
rp, err := b.renderPost(post)
|
||
|
|
if err != nil {
|
||
|
|
return postSingleData{}, err
|
||
|
|
}
|
||
|
|
|
||
|
|
if b.site.CategoriesOfPost != nil {
|
||
|
|
cats, err := b.site.CategoriesOfPost(ctx, post.ID)
|
||
|
|
if err != nil {
|
||
|
|
return postSingleData{}, err
|
||
|
|
}
|
||
|
|
rp.Categories = cats
|
||
|
|
}
|
||
|
|
|
||
|
|
return rp, nil
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 8: Update BuildSite to render categories and attach categories to posts**
|
||
|
|
|
||
|
|
Modify `BuildSite` in `providers/sitebuilder/builder.go`. Update the post-writing goroutine and the post-list goroutine to use `renderPostWithCategories`. Add new goroutines for category pages:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (b *Builder) BuildSite(outDir string) error {
|
||
|
|
buildCtx := buildContext{outDir: outDir}
|
||
|
|
|
||
|
|
if err := os.RemoveAll(outDir); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
eg, ctx := errgroup.WithContext(context.Background())
|
||
|
|
|
||
|
|
eg.Go(func() error {
|
||
|
|
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
|
||
|
|
}
|
||
|
|
if err := b.createAtPath(buildCtx, rp.Path, func(f io.Writer) error {
|
||
|
|
return b.renderTemplate(f, tmplNamePostSingle, rp)
|
||
|
|
}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
|
||
|
|
eg.Go(func() error {
|
||
|
|
return b.renderPostListWithCategories(buildCtx, ctx)
|
||
|
|
})
|
||
|
|
|
||
|
|
eg.Go(func() error {
|
||
|
|
if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx), feedOptions{
|
||
|
|
targetNamePrefix: "/feed",
|
||
|
|
titlePrefix: "",
|
||
|
|
}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx), feedOptions{
|
||
|
|
targetNamePrefix: "/feeds/microblog-crosspost",
|
||
|
|
titlePrefix: "Devlog: ",
|
||
|
|
}); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return nil
|
||
|
|
})
|
||
|
|
|
||
|
|
// Category pages
|
||
|
|
eg.Go(func() error {
|
||
|
|
if err := b.renderCategoryList(buildCtx); err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
return b.renderCategoryPages(buildCtx, ctx)
|
||
|
|
})
|
||
|
|
|
||
|
|
// Copy uploads
|
||
|
|
eg.Go(func() error {
|
||
|
|
return b.writeUploads(buildCtx, b.site.Uploads)
|
||
|
|
})
|
||
|
|
|
||
|
|
return eg.Wait()
|
||
|
|
}
|
||
|
|
|
||
|
|
func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error {
|
||
|
|
var posts []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
|
||
|
|
}
|
||
|
|
posts = append(posts, rp)
|
||
|
|
}
|
||
|
|
|
||
|
|
pl := postListData{
|
||
|
|
commonData: commonData{Site: b.site},
|
||
|
|
Posts: posts,
|
||
|
|
}
|
||
|
|
|
||
|
|
return b.createAtPath(bctx, "", func(f io.Writer) error {
|
||
|
|
return b.renderTemplate(f, tmplNamePostList, pl)
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Remove the old `writePost` and `renderPostList` methods as they are replaced.
|
||
|
|
|
||
|
|
- [ ] **Step 8b: Add category metadata to main feeds**
|
||
|
|
|
||
|
|
The `feedhub.Item` struct has a `Category string` field. Update `renderFeeds` in `builder.go` to populate it. After the post is rendered, look up its categories and join the names:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// In renderFeeds, after renderedPost is created, add:
|
||
|
|
var catName string
|
||
|
|
if b.site.CategoriesOfPost != nil {
|
||
|
|
cats, err := b.site.CategoriesOfPost(context.Background(), post.ID)
|
||
|
|
if err == nil && len(cats) > 0 {
|
||
|
|
names := make([]string, len(cats))
|
||
|
|
for i, c := range cats {
|
||
|
|
names[i] = c.Name
|
||
|
|
}
|
||
|
|
catName = strings.Join(names, ", ")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Then in the feed.Items append, add:
|
||
|
|
Category: catName,
|
||
|
|
```
|
||
|
|
|
||
|
|
This adds category names to each post entry in the main RSS/JSON feeds.
|
||
|
|
|
||
|
|
- [ ] **Step 9: Add postIterByCategory to publisher/iter.go**
|
||
|
|
|
||
|
|
Add to `services/publisher/iter.go`:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
|
||
|
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||
|
|
paging := db.PagingParams{Offset: 0, Limit: 50}
|
||
|
|
for {
|
||
|
|
page, err := s.db.SelectPostsOfCategory(ctx, categoryID, paging)
|
||
|
|
if err != nil {
|
||
|
|
yield(models.Maybe[*models.Post]{Err: err})
|
||
|
|
return
|
||
|
|
}
|
||
|
|
if len(page) == 0 {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
for _, post := range page {
|
||
|
|
if !yield(models.Maybe[*models.Post]{Value: post}) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
paging.Offset += paging.Limit
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 10: Populate category data in publisher/service.go**
|
||
|
|
|
||
|
|
In `services/publisher/service.go`, inside the `Publish` method, after fetching uploads and before the target loop, fetch categories:
|
||
|
|
|
||
|
|
```go
|
||
|
|
// Fetch categories with counts
|
||
|
|
cats, err := p.db.SelectCategoriesOfSite(ctx, site.ID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
var catsWithCounts []models.CategoryWithCount
|
||
|
|
for _, cat := range cats {
|
||
|
|
count, err := p.db.CountPostsOfCategory(ctx, cat.ID)
|
||
|
|
if err != nil {
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
catsWithCounts = append(catsWithCounts, models.CategoryWithCount{
|
||
|
|
Category: *cat,
|
||
|
|
PostCount: int(count),
|
||
|
|
DescriptionBrief: briefDescription(cat.Description),
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add the `briefDescription` helper (same as in categories service — or extract to models):
|
||
|
|
|
||
|
|
```go
|
||
|
|
func briefDescription(desc string) string {
|
||
|
|
if desc == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
for i, c := range desc {
|
||
|
|
if c == '\n' {
|
||
|
|
return desc[:i]
|
||
|
|
}
|
||
|
|
if c == '.' && i+1 < len(desc) {
|
||
|
|
return desc[:i+1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return desc
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update the `pubSite` construction to include category fields:
|
||
|
|
|
||
|
|
```go
|
||
|
|
pubSite := pubmodel.Site{
|
||
|
|
Site: site,
|
||
|
|
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
||
|
|
return p.postIter(ctx, site.ID)
|
||
|
|
},
|
||
|
|
BaseURL: target.BaseURL,
|
||
|
|
Uploads: uploads,
|
||
|
|
Categories: catsWithCounts,
|
||
|
|
PostIterByCategory: func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]] {
|
||
|
|
return p.postIterByCategory(ctx, categoryID)
|
||
|
|
},
|
||
|
|
CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) {
|
||
|
|
return p.db.SelectCategoriesOfPost(ctx, postID)
|
||
|
|
},
|
||
|
|
OpenUpload: func(u models.Upload) (io.ReadCloser, error) {
|
||
|
|
return p.up.OpenUpload(site, u)
|
||
|
|
},
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **Step 11: Move briefDescription to models package**
|
||
|
|
|
||
|
|
To avoid duplication, move `briefDescription` to `models/categories.go` as an exported function `BriefDescription`, and update both `services/categories/service.go` and `services/publisher/service.go` to call `models.BriefDescription()`.
|
||
|
|
|
||
|
|
In `models/categories.go`, rename/add:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func BriefDescription(desc string) string {
|
||
|
|
if desc == "" {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
for i, c := range desc {
|
||
|
|
if c == '\n' {
|
||
|
|
return desc[:i]
|
||
|
|
}
|
||
|
|
if c == '.' && i+1 < len(desc) {
|
||
|
|
return desc[:i+1]
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return desc
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Update `services/categories/service.go` to use `models.BriefDescription()`.
|
||
|
|
Update `services/publisher/service.go` to use `models.BriefDescription()` and remove local copy.
|
||
|
|
|
||
|
|
- [ ] **Step 12: Fix the existing builder test**
|
||
|
|
|
||
|
|
The existing test in `providers/sitebuilder/builder_test.go` uses `pubmodel.Site.Posts` which no longer exists. Update it to use `PostIter` and add the new category templates to the template map:
|
||
|
|
|
||
|
|
```go
|
||
|
|
func TestBuilder_BuildSite(t *testing.T) {
|
||
|
|
t.Run("build site", func(t *testing.T) {
|
||
|
|
tmpls := fstest.MapFS{
|
||
|
|
"posts_single.html": {Data: []byte(`{{ .HTML }}`)},
|
||
|
|
"posts_list.html": {Data: []byte(`{{ range .Posts}}<a href="{{url_abs .Path}}">{{.Post.Title}}</a>,{{ end }}`)},
|
||
|
|
"layout_main.html": {Data: []byte(`{{ .Body }}`)},
|
||
|
|
"categories_list.html": {Data: []byte(`{{ range .Categories}}<a href="{{url_abs .Path}}">{{.Name}}</a>,{{ end }}`)},
|
||
|
|
"categories_single.html": {Data: []byte(`<h2>{{.Category.Name}}</h2>`)},
|
||
|
|
}
|
||
|
|
|
||
|
|
posts := []*models.Post{
|
||
|
|
{
|
||
|
|
Title: "Test Post",
|
||
|
|
Slug: "/2026/02/18/test-post",
|
||
|
|
Body: "This is a test post",
|
||
|
|
},
|
||
|
|
{
|
||
|
|
Title: "Another Post",
|
||
|
|
Slug: "/2026/02/20/another-post",
|
||
|
|
Body: "This is **another** test post",
|
||
|
|
},
|
||
|
|
}
|
||
|
|
|
||
|
|
site := pubmodel.Site{
|
||
|
|
BaseURL: "https://example.com",
|
||
|
|
PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] {
|
||
|
|
return func(yield func(models.Maybe[*models.Post]) bool) {
|
||
|
|
for _, p := range posts {
|
||
|
|
if !yield(models.Maybe[*models.Post]{Value: p}) {
|
||
|
|
return
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
},
|
||
|
|
}
|
||
|
|
wantFiles := map[string]string{
|
||
|
|
"2026/02/18/test-post/index.html": "<p>This is a test post</p>\n",
|
||
|
|
"2026/02/20/another-post/index.html": "<p>This is <strong>another</strong> test post</p>\n",
|
||
|
|
"index.html": "<a href=\"https://example.com/2026/02/18/test-post\">Test Post</a>,<a href=\"https://example.com/2026/02/20/another-post\">Another Post</a>,",
|
||
|
|
}
|
||
|
|
|
||
|
|
outDir := t.TempDir()
|
||
|
|
|
||
|
|
b, err := sitebuilder.New(site, sitebuilder.Options{
|
||
|
|
TemplatesFS: tmpls,
|
||
|
|
})
|
||
|
|
assert.NoError(t, err)
|
||
|
|
|
||
|
|
err = b.BuildSite(outDir)
|
||
|
|
assert.NoError(t, err)
|
||
|
|
|
||
|
|
for file, content := range wantFiles {
|
||
|
|
filePath := filepath.Join(outDir, file)
|
||
|
|
fileContent, err := os.ReadFile(filePath)
|
||
|
|
assert.NoError(t, err)
|
||
|
|
assert.Equal(t, content, string(fileContent))
|
||
|
|
}
|
||
|
|
})
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Add imports: `"context"`, `"iter"`.
|
||
|
|
|
||
|
|
- [ ] **Step 13: Fix the existing DB test**
|
||
|
|
|
||
|
|
Update calls to `SelectPostsOfSite` in `providers/db/provider_test.go` to include the `PagingParams` argument:
|
||
|
|
|
||
|
|
Replace all occurrences of `p.SelectPostsOfSite(ctx, <siteID>, <bool>)` with `p.SelectPostsOfSite(ctx, <siteID>, <bool>, db.PagingParams{Limit: 100})`.
|
||
|
|
|
||
|
|
- [ ] **Step 14: Verify the app compiles and tests pass**
|
||
|
|
|
||
|
|
Run: `go build ./...` and `go test ./models/ ./providers/db/ ./providers/sitebuilder/ -v`
|
||
|
|
Expected: No errors, all tests PASS.
|
||
|
|
|
||
|
|
- [ ] **Step 15: Commit**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add models/pubmodel/sites.go models/categories.go providers/sitebuilder/ layouts/simplecss/ services/publisher/ services/categories/service.go providers/db/provider_test.go
|
||
|
|
git commit -m "feat: add category pages and per-category feeds to site builder"
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Task 8: Final Verification
|
||
|
|
|
||
|
|
- [ ] **Step 1: Verify full build**
|
||
|
|
|
||
|
|
Run: `go build ./...`
|
||
|
|
Expected: No errors (sitereader may have pre-existing issues — that's OK).
|
||
|
|
|
||
|
|
- [ ] **Step 2: Run all tests**
|
||
|
|
|
||
|
|
Run: `go test ./...`
|
||
|
|
Expected: All tests pass (pre-existing failures in sitereader/handlers are OK).
|
||
|
|
|
||
|
|
- [ ] **Step 3: Manual smoke test checklist**
|
||
|
|
|
||
|
|
If running the app locally, verify:
|
||
|
|
1. Navigate to `/sites/<id>/categories` — empty list shows
|
||
|
|
2. Create a new category with name, slug, description
|
||
|
|
3. Edit the category — changes persist
|
||
|
|
4. Delete the category — removed from list
|
||
|
|
5. Edit a post — category sidebar appears on the right
|
||
|
|
6. Select categories on a post, save — categories persist on reload
|
||
|
|
7. Post list shows category badges
|
||
|
|
8. Rebuild site — category index, archive pages, and feeds are generated
|
||
|
|
9. Empty categories do not appear on published site
|
||
|
|
|
||
|
|
- [ ] **Step 4: Final commit if any cleanup needed**
|
||
|
|
|
||
|
|
```bash
|
||
|
|
git add -A
|
||
|
|
git commit -m "chore: categories feature cleanup"
|
||
|
|
```
|