feat: add categories admin UI with CRUD

Wire up categories service, add CategoriesHandler with full CRUD, create index/edit templates, register routes in server.go, and add Categories nav link.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Leon Mika 2026-03-18 21:42:17 +11:00
parent 3c80f63a55
commit ffa86b12e9
6 changed files with 198 additions and 0 deletions

View file

@ -112,6 +112,7 @@ Starting weiro without any arguments will start the server.
ph := handlers.PostsHandler{PostService: svcs.Posts}
uh := handlers.UploadsHandler{UploadsService: svcs.Uploads}
ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites}
ch := handlers.CategoriesHandler{CategoryService: svcs.Categories}
app.Get("/login", lh.Login)
app.Post("/login", lh.DoLogin)
@ -141,6 +142,13 @@ Starting weiro without any arguments will start the server.
siteGroup.Get("/settings", ssh.General)
siteGroup.Post("/settings", ssh.UpdateGeneral)
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)
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)

101
handlers/categories.go Normal file
View file

@ -0,0 +1,101 @@
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))
}

View file

@ -7,6 +7,7 @@ import (
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/providers/uploadfiles"
"lmika.dev/lmika/weiro/services/auth"
"lmika.dev/lmika/weiro/services/categories"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/services/sites"
@ -21,6 +22,7 @@ type Services struct {
Posts *posts.Service
Sites *sites.Service
Uploads *uploads.Service
Categories *categories.Service
}
func New(cfg config.Config) (*Services, error) {
@ -37,6 +39,7 @@ func New(cfg config.Config) (*Services, error) {
postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp)
uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending"))
categoriesService := categories.New(dbp, publisherQueue)
return &Services{
DB: dbp,
@ -46,6 +49,7 @@ func New(cfg config.Config) (*Services, error) {
Posts: postService,
Sites: siteService,
Uploads: uploadService,
Categories: categoriesService,
}, nil
}

View file

@ -10,6 +10,9 @@
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/posts">Posts</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/categories">Categories</a>
</li>
<li class="nav-item">
<a class="nav-link active" aria-current="page" href="/sites/{{.site.ID}}/uploads">Uploads</a>
</li>

View file

@ -0,0 +1,47 @@
<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>

View file

@ -0,0 +1,35 @@
<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>