From 21f181f83d2b293c06c8e4d646c6b9f0a920f871 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 5 Mar 2026 22:04:24 +1100 Subject: [PATCH 01/62] Added RSS and JSON feeds --- go.mod | 1 + go.sum | 2 + layouts/simplecss/layout_main.html | 2 + models/iters.go | 10 +++ models/pubmodel/sites.go | 7 +- providers/db/gen/sqlgen/posts.sql.go | 13 +++- providers/db/posts.go | 9 ++- providers/sitebuilder/builder.go | 102 ++++++++++++++++++++++++--- providers/sitebuilder/tmpls.go | 10 ++- services/posts/list.go | 6 +- services/publisher/iter.go | 37 ++++++++++ services/publisher/service.go | 20 +++--- sql/queries/posts.sql | 4 +- 13 files changed, 192 insertions(+), 31 deletions(-) create mode 100644 models/iters.go create mode 100644 services/publisher/iter.go diff --git a/go.mod b/go.mod index 4178f45..846e1e6 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,7 @@ require ( github.com/gofiber/template/v2 v2.1.0 // indirect github.com/gofiber/utils/v2 v2.0.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gopherlibs/feedhub v1.2.0 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/go.sum b/go.sum index 0211568..c1a1202 100644 --- a/go.sum +++ b/go.sum @@ -279,6 +279,8 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gopherlibs/feedhub v1.2.0 h1:1nfM8gRoiA+VNjKc1FzrwiXkrBKsnAghA3PVvgAiSI0= +github.com/gopherlibs/feedhub v1.2.0/go.mod h1:vvQEZzTKr2KhO0mCdEUGfKLvUJFfO8U+WUpaMyoZttc= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/handlers v1.4.2/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ= diff --git a/layouts/simplecss/layout_main.html b/layouts/simplecss/layout_main.html index 5c4203e..cc2e616 100644 --- a/layouts/simplecss/layout_main.html +++ b/layouts/simplecss/layout_main.html @@ -4,6 +4,8 @@ {{ .Site.Title }} + + diff --git a/models/iters.go b/models/iters.go new file mode 100644 index 0000000..8d9d52a --- /dev/null +++ b/models/iters.go @@ -0,0 +1,10 @@ +package models + +type Maybe[T any] struct { + Value T + Err error +} + +func (m Maybe[T]) Get() (T, error) { + return m.Value, m.Err +} diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index 6fc3942..a745885 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -1,7 +1,9 @@ package pubmodel import ( + "context" "io" + "iter" "lmika.dev/lmika/weiro/models" ) @@ -9,8 +11,11 @@ import ( type Site struct { models.Site BaseURL string - Posts []*models.Post + //Posts []*models.Post Uploads []models.Upload OpenUpload func(u models.Upload) (io.ReadCloser, error) + + // PostItr returns a new post iterator + PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] } diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index e4b00f0..d512941 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -123,21 +123,28 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro const selectPostsOfSite = `-- name: SelectPostsOfSite :many SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts -WHERE site_id = ? AND ( +WHERE site_id = ?1 AND ( CASE CAST (?2 AS TEXT) WHEN 'deleted' THEN deleted_at > 0 ELSE deleted_at = 0 END -) ORDER BY created_at DESC LIMIT 10 +) ORDER BY created_at DESC LIMIT ?4 OFFSET ?3 ` type SelectPostsOfSiteParams struct { SiteID int64 PostFilter string + Offset int64 + Limit int64 } func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) { - rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter) + rows, err := q.db.QueryContext(ctx, selectPostsOfSite, + arg.SiteID, + arg.PostFilter, + arg.Offset, + arg.Limit, + ) if err != nil { return nil, err } diff --git a/providers/db/posts.go b/providers/db/posts.go index 04a7a3e..218e931 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -8,7 +8,12 @@ import ( "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" ) -func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) { +type PagingParams struct { + Limit int64 + Offset int64 +} + +func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool, pp PagingParams) ([]*models.Post, error) { var filter = "" if showDeleted { filter = "deleted" @@ -17,6 +22,8 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{ SiteID: siteID, PostFilter: filter, + Limit: pp.Limit, + Offset: pp.Offset, }) if err != nil { return nil, err diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index acbb62b..16d4fa6 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -6,11 +6,13 @@ import ( "fmt" "html/template" "io" + "iter" "os" "path/filepath" - "sort" "strings" + "time" + "github.com/gopherlibs/feedhub/feedhub" "golang.org/x/sync/errgroup" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models/pubmodel" @@ -47,10 +49,14 @@ func (b *Builder) BuildSite(outDir string) error { return err } - eg := errgroup.Group{} + eg, ctx := errgroup.WithContext(context.Background()) eg.Go(func() error { - for _, post := range b.site.Posts { + for mp := range b.site.PostIter(ctx) { + post, err := mp.Get() + if err != nil { + return err + } if err := b.writePost(buildCtx, post); err != nil { return err } @@ -59,7 +65,14 @@ func (b *Builder) BuildSite(outDir string) error { }) eg.Go(func() error { - if err := b.renderPostList(buildCtx, b.site.Posts); err != nil { + if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil { + return err + } + return nil + }) + + eg.Go(func() error { + if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx)); err != nil { return err } return nil @@ -79,14 +92,16 @@ func (b *Builder) BuildSite(outDir string) error { return nil } -func (b *Builder) renderPostList(ctx buildContext, postList []*models.Post) error { +func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error { // TODO: paging - postCopy := make([]*models.Post, len(postList)) - copy(postCopy, postList) - - sort.Slice(postCopy, func(i, j int) bool { - return postCopy[i].PublishedAt.After(postCopy[j].PublishedAt) - }) + postCopy := make([]*models.Post, 0) + for mp := range postIter { + post, err := mp.Get() + if err != nil { + return err + } + postCopy = append(postCopy, post) + } pl := postListData{ commonData: commonData{Site: b.site}, @@ -104,6 +119,68 @@ func (b *Builder) renderPostList(ctx buildContext, postList []*models.Post) erro }) } +func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error { + now := time.Now() + feed := &feedhub.Feed{ + Title: b.site.Title, + Link: &feedhub.Link{Href: b.site.BaseURL}, + Description: b.site.Tagline, + // TO FIX: Author + Created: now, + } + + items := 0 + for mp := range postIter { + post, err := mp.Get() + if err != nil { + return fmt.Errorf("failed to get post: %w", err) + } + + renderedPost, err := b.renderPost(post) + if err != nil { + return err + } + + feed.Items = append(feed.Items, &feedhub.Item{ + Id: filepath.Join(b.site.BaseURL, post.GUID), + Title: post.Title, + Link: &feedhub.Link{Href: renderedPost.PostURL}, + Content: string(renderedPost.HTML), + // TO FIX: Created should be first published + Created: post.PublishedAt, + Updated: post.PublishedAt, + }) + + items++ + if items >= b.opts.FeedItems { + break + } + } + + if err := b.createAtPath(ctx, "/feed.xml", func(f io.Writer) error { + rss, err := feed.ToRss() + if err != nil { + return fmt.Errorf("failed to convert feed to RSS: %w", err) + } + _, err = io.WriteString(f, rss) + return err + }); err != nil { + return err + } + + if err := b.createAtPath(ctx, "/feed.json", func(f io.Writer) error { + rss, err := feed.ToJSON() + if err != nil { + return fmt.Errorf("failed to convert feed to JSON feed: %w", err) + } + _, err = io.WriteString(f, rss) + return err + }); err != nil { + return err + } + return nil +} + func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { postPath := post.Slug if b.opts.BasePosts != "" { @@ -115,10 +192,13 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err) } + postURL := strings.TrimSuffix(b.site.BaseURL, "/") + "/" + strings.TrimPrefix(postPath, "/") + return postSingleData{ commonData: commonData{Site: b.site}, Path: postPath, Post: post, + PostURL: postURL, HTML: template.HTML(md.String()), }, nil } diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index 812a14d..fa70b6d 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -29,6 +29,9 @@ type Options struct { // TemplatesFS provides the raw templates for rendering the site. TemplatesFS fs.FS + // FeedItems holds the number of posts to show in the feed. + FeedItems int + RenderTZ *time.Location } @@ -38,9 +41,10 @@ type commonData struct { type postSingleData struct { commonData - Post *models.Post - HTML template.HTML - Path string + Post *models.Post + HTML template.HTML + Path string + PostURL string } type postListData struct { diff --git a/services/posts/list.go b/services/posts/list.go index e94b24b..ae70e1c 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -4,6 +4,7 @@ import ( "context" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" ) func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) { @@ -12,7 +13,10 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Po return nil, models.SiteRequiredError } - posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted) + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ + Offset: 0, + Limit: 25, + }) if err != nil { return nil, err } diff --git a/services/publisher/iter.go b/services/publisher/iter.go new file mode 100644 index 0000000..a125fb1 --- /dev/null +++ b/services/publisher/iter.go @@ -0,0 +1,37 @@ +package publisher + +import ( + "context" + "iter" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" +) + +// PostIter returns a post iterator which returns posts in reverse chronological order. +func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { + return func(yield func(models.Maybe[*models.Post]) bool) { + paging := db.PagingParams{Offset: 0, Limit: 50} + page, err := s.db.SelectPostsOfSite(ctx, site, false, paging) + if err != nil { + yield(models.Maybe[*models.Post]{Err: err}) + return + } + + for { + for _, post := range page { + if !yield(models.Maybe[*models.Post]{Value: post}) { + return + } + } + paging.Offset += paging.Limit + page, err = s.db.SelectPostsOfSite(ctx, site, false, paging) + if err != nil { + yield(models.Maybe[*models.Post]{Err: err}) + return + } else if len(page) == 0 { + return + } + } + } +} diff --git a/services/publisher/service.go b/services/publisher/service.go index 7026bca..ec9834e 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -3,6 +3,7 @@ package publisher import ( "context" "io" + "iter" "log" "os" @@ -38,12 +39,6 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { return err } - // Fetch all content of site - posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false) - if err != nil { - return err - } - // Fetch all uploads of site uploads, err := p.db.SelectUploadsOfSite(ctx, site.ID) if err != nil { @@ -56,8 +51,10 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { } pubSite := pubmodel.Site{ - Site: site, - Posts: posts, + Site: site, + PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { + return p.postIter(ctx, site.ID) + }, BaseURL: target.BaseURL, Uploads: uploads, OpenUpload: func(u models.Upload) (io.ReadCloser, error) { @@ -77,6 +74,7 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ BasePosts: "/posts", TemplatesFS: simplecss.FS, + FeedItems: 30, }) if err != nil { return err @@ -88,7 +86,11 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ if err := exporter.WriteSiteYAML(); err != nil { return err } - for _, p := range pubSite.Posts { + for mp := range pubSite.PostIter(ctx) { + p, err := mp.Get() + if err != nil { + return err + } if err := exporter.WritePost(p); err != nil { return err } diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 3f740bf..dae1f39 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,12 +1,12 @@ -- name: SelectPostsOfSite :many SELECT * FROM posts -WHERE site_id = ? AND ( +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 -) ORDER BY created_at DESC LIMIT 10; +) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); -- name: SelectPost :one SELECT * FROM posts WHERE id = ? LIMIT 1; From 1b243860011ec20a173548208dde8071b8afd70f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 5 Mar 2026 22:27:14 +1100 Subject: [PATCH 02/62] Added the scratch dir config --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index 95faeea..6ec53e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,6 +25,7 @@ FROM alpine:latest RUN apk --no-cache add ca-certificates RUN mkdir -p /data +RUN mkdir -p /scratch WORKDIR /root/ @@ -34,6 +35,7 @@ COPY --from=builder /app/static ./static COPY --from=builder /app/views ./views ENV DATA_DIR=/data +ENV SCRATCH_DIR=/scratch EXPOSE 3000 From 891d904d9c8d6690bc00ba489c55b42c2b293cc0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 5 Mar 2026 22:37:11 +1100 Subject: [PATCH 03/62] Added error logging --- cmds/server.go | 2 +- config/config.go | 2 +- handlers/middleware/errlog.go | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 handlers/middleware/errlog.go diff --git a/cmds/server.go b/cmds/server.go index c39f233..1e368ce 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -116,7 +116,7 @@ Starting weiro without any arguments will start the server. app.Post("/login", lh.DoLogin) app.Post("/logout", lh.Logout) - siteGroup := app.Group("/sites/:siteID", middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites)) + siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites)) siteGroup.Get("/posts", ph.Index) siteGroup.Get("/posts/new", ph.New) diff --git a/config/config.go b/config/config.go index 56585a7..d607b1b 100644 --- a/config/config.go +++ b/config/config.go @@ -9,7 +9,7 @@ import ( type Config struct { DataDir string `env:"DATA_DIR"` - ScratchDir string `env:"SCRATCH_DIR"` + ScratchDir string `env:"SCRATCH_DIR,default=/tmp"` SiteDomain string `env:"SITE_DOMAIN"` LoginLocked bool `env:"LOGIN_LOCKED,default=false"` Env string `env:"ENV,default=prod"` diff --git a/handlers/middleware/errlog.go b/handlers/middleware/errlog.go new file mode 100644 index 0000000..5b6dfa6 --- /dev/null +++ b/handlers/middleware/errlog.go @@ -0,0 +1,17 @@ +package middleware + +import ( + "log" + + "github.com/gofiber/fiber/v3" +) + +func LogErrors() func(c fiber.Ctx) error { + return func(c fiber.Ctx) error { + if err := c.Next(); err != nil { + log.Printf("error: %v\n", err) + return err + } + return nil + } +} From 76ed54f1195647102eeac01ec5a7244520670b50 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 5 Mar 2026 22:48:38 +1100 Subject: [PATCH 04/62] Added fallback if renaming cant move an upload --- providers/sitebuilder/builder.go | 2 +- providers/uploadfiles/provider.go | 36 ++++++++++++++++++++++++++++++- services/posts/create.go | 6 ++++-- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 16d4fa6..3417fba 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -148,7 +148,7 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* Content: string(renderedPost.HTML), // TO FIX: Created should be first published Created: post.PublishedAt, - Updated: post.PublishedAt, + Updated: post.UpdatedAt, }) items++ diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index eb7d5de..2eb84e4 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -26,12 +26,46 @@ func (p *Provider) AdoptFile(site models.Site, up models.Upload, filename string return err } - if err := os.Rename(filename, fullPath); err != nil { + if err := os.Rename(filename, fullPath); err == nil { + return nil + } + + // Can't rename, possibly because of a cross-link device issue. So copy instead + if err := moveFile(filename, fullPath); err != nil { return err } + return nil } +func moveFile(src, dst string) error { + if err := copyFile(src, dst); err != nil { + _ = os.Remove(dst) + return err + } + _ = os.Remove(src) + return nil +} + +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err = io.Copy(out, in); err != nil { + return err + } + return err +} + func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) { fullPath := p.uploadFileName(site, up) return os.Open(fullPath) diff --git a/services/posts/create.go b/services/posts/create.go index 1dc69a1..4c99b82 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -17,6 +17,8 @@ type CreatePostParams struct { } func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) { + now := time.Now() + site, ok := models.GetSite(ctx) if !ok { return nil, models.SiteRequiredError @@ -29,14 +31,14 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod post.Title = params.Title post.Body = params.Body - post.UpdatedAt = time.Now() + post.UpdatedAt = now post.Slug = post.BestSlug() oldState := post.State switch strings.ToLower(params.Action) { case "publish": post.State = models.StatePublished - post.PublishedAt = time.Now() + post.PublishedAt = now case "save draft": post.State = models.StateDraft post.PublishedAt = time.Time{} From fa9be690451b1c7332994e8604194581e4335d69 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 8 Mar 2026 09:37:49 +1100 Subject: [PATCH 05/62] Added feeds for crossposting and a rebuild site button --- cmds/server.go | 3 +++ handlers/posts.go | 14 +++++++++++++- providers/sitebuilder/builder.go | 29 ++++++++++++++++++++--------- providers/sitebuilder/models.go | 10 ++++++++++ services/posts/create.go | 10 ++++++++++ views/posts/index.html | 7 ++++++- 6 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 providers/sitebuilder/models.go diff --git a/cmds/server.go b/cmds/server.go index 1e368ce..2445b8a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -125,6 +125,9 @@ Starting weiro without any arguments will start the server. siteGroup.Patch("/posts/:postID", ph.Patch) siteGroup.Delete("/posts/:postID", ph.Delete) + // TODO Move + siteGroup.Post("/rebuild", ph.Rebuild) + siteGroup.Get("/uploads", uh.Index) siteGroup.Get("/uploads/slug/+", uh.ShowFromSlug) siteGroup.Get("/uploads/:uploadID", uh.Show) diff --git a/handlers/posts.go b/handlers/posts.go index 1bab15d..283ffd9 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -149,6 +149,18 @@ func (ph PostsHandler) Delete(c fiber.Ctx) error { return accepts(c, json(func() any { return fiber.Map{} }), html(func(c fiber.Ctx) error { - return c.Redirect().To("/sites") + return c.Redirect().To("/") + })) +} + +func (ph PostsHandler) Rebuild(c fiber.Ctx) error { + if err := ph.PostService.RebuildSite(c.Context()); err != nil { + return err + } + + return accepts(c, json(func() any { + return fiber.Map{} + }), html(func(c fiber.Ctx) error { + return c.Redirect().To("/") })) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 3417fba..9b25fb1 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -72,7 +72,17 @@ func (b *Builder) BuildSite(outDir string) error { }) eg.Go(func() error { - if err := b.renderFeeds(buildCtx, b.site.PostIter(ctx)); err != nil { + 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 @@ -119,7 +129,7 @@ func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Mayb }) } -func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error { +func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]], opts feedOptions) error { now := time.Now() feed := &feedhub.Feed{ Title: b.site.Title, @@ -141,9 +151,14 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* return err } + postTitle := post.Title + if postTitle != "" { + postTitle = opts.titlePrefix + postTitle + } + feed.Items = append(feed.Items, &feedhub.Item{ Id: filepath.Join(b.site.BaseURL, post.GUID), - Title: post.Title, + Title: postTitle, Link: &feedhub.Link{Href: renderedPost.PostURL}, Content: string(renderedPost.HTML), // TO FIX: Created should be first published @@ -157,7 +172,7 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* } } - if err := b.createAtPath(ctx, "/feed.xml", func(f io.Writer) error { + if err := b.createAtPath(ctx, opts.targetNamePrefix+".xml", func(f io.Writer) error { rss, err := feed.ToRss() if err != nil { return fmt.Errorf("failed to convert feed to RSS: %w", err) @@ -168,7 +183,7 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* return err } - if err := b.createAtPath(ctx, "/feed.json", func(f io.Writer) error { + if err := b.createAtPath(ctx, opts.targetNamePrefix+".json", func(f io.Writer) error { rss, err := feed.ToJSON() if err != nil { return fmt.Errorf("failed to convert feed to JSON feed: %w", err) @@ -276,7 +291,3 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error } return nil } - -type buildContext struct { - outDir string -} diff --git a/providers/sitebuilder/models.go b/providers/sitebuilder/models.go new file mode 100644 index 0000000..30419c6 --- /dev/null +++ b/providers/sitebuilder/models.go @@ -0,0 +1,10 @@ +package sitebuilder + +type buildContext struct { + outDir string +} + +type feedOptions struct { + targetNamePrefix string + titlePrefix string +} diff --git a/services/posts/create.go b/services/posts/create.go index 4c99b82..52b9400 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -79,3 +79,13 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param } return post, nil } + +// TEMP - to move +func (s *Service) RebuildSite(ctx context.Context) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + s.publisher.Queue(site) + return nil +} diff --git a/views/posts/index.html b/views/posts/index.html index 83f0b8b..252c9c9 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -1,7 +1,12 @@ {{ $showingTrash := eq .req.Filter "deleted" }}
- New Post +
+ New Post +
+ +
+
From 7c08e1fbe04e568b4bca7be9e089b5eb9a6ba7b0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 8 Mar 2026 09:54:46 +1100 Subject: [PATCH 06/62] Fixed bug which was exposing draft posts --- services/publisher/iter.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/services/publisher/iter.go b/services/publisher/iter.go index a125fb1..48b5252 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -20,6 +20,10 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma for { for _, post := range page { + if post.State != models.StatePublished { + continue + } + if !yield(models.Maybe[*models.Post]{Value: post}) { return } From 499c0d85683625e12692dbe1c8c2845dffe8329c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 9 Mar 2026 09:32:32 +1100 Subject: [PATCH 07/62] Set image URLs to absolute paths --- assets/js/controllers/postedit.js | 7 +++++- providers/sitebuilder/builder.go | 33 +++++++++++++++++++++---- providers/sitebuilder/processors.go | 37 +++++++++++++++++++++++++++++ providers/sitebuilder/tmplfns.go | 8 +++++-- 4 files changed, 78 insertions(+), 7 deletions(-) create mode 100644 providers/sitebuilder/processors.go diff --git a/assets/js/controllers/postedit.js b/assets/js/controllers/postedit.js index 54679c5..71328e3 100644 --- a/assets/js/controllers/postedit.js +++ b/assets/js/controllers/postedit.js @@ -14,11 +14,16 @@ export default class PosteditController extends Controller { async save(ev) { ev.preventDefault(); + showToast({ + title: "💾 Saving Post", + body: (this.saveActionValue === "Save Draft") ? "Saving post as draft…" : "Updating post…", + }); + try { await this._postForm(this.saveActionValue); showToast({ - title: "💾 Post Saved", + title: "💾 Saving Post", body: (this.saveActionValue === "Save Draft") ? "Post saved as draft." : "Post updated.", }); } catch (e) { diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 9b25fb1..4908d61 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -12,6 +12,7 @@ import ( "strings" "time" + "github.com/PuerkitoBio/goquery" "github.com/gopherlibs/feedhub/feedhub" "golang.org/x/sync/errgroup" "lmika.dev/lmika/weiro/models" @@ -20,10 +21,11 @@ import ( ) type Builder struct { - site pubmodel.Site - mdRenderer *markdown.Renderer - opts Options - tmpls *template.Template + site pubmodel.Site + mdRenderer *markdown.Renderer + opts Options + tmpls *template.Template + postMDProcessors []postMDProcessor } func New(site pubmodel.Site, opts Options) (*Builder, error) { @@ -39,6 +41,9 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) { opts: opts, tmpls: tmpls, mdRenderer: markdown.NewRendererForSite(), + postMDProcessors: []postMDProcessor{ + uploadAbsoluteURL, + }, }, nil } @@ -207,6 +212,26 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { return postSingleData{}, fmt.Errorf("failed to write post %s: %w", post.Slug, err) } + if len(b.postMDProcessors) > 0 { + dom, err := goquery.NewDocumentFromReader(&md) + if err != nil { + return postSingleData{}, fmt.Errorf("failed to parse post %s: %w", post.Slug, err) + } + + for _, processor := range b.postMDProcessors { + if err := processor(b.site, dom); err != nil { + return postSingleData{}, fmt.Errorf("failed to process post %s: %w", post.Slug, err) + } + } + + outHTML, err := dom.Find("body").Html() + if err != nil { + return postSingleData{}, fmt.Errorf("failed to render post %s: %w", post.Slug, err) + } + md.Reset() + md.WriteString(outHTML) + } + postURL := strings.TrimSuffix(b.site.BaseURL, "/") + "/" + strings.TrimPrefix(postPath, "/") return postSingleData{ diff --git a/providers/sitebuilder/processors.go b/providers/sitebuilder/processors.go new file mode 100644 index 0000000..c699160 --- /dev/null +++ b/providers/sitebuilder/processors.go @@ -0,0 +1,37 @@ +package sitebuilder + +import ( + "net/url" + "strings" + + "github.com/PuerkitoBio/goquery" + "lmika.dev/lmika/weiro/models/pubmodel" +) + +type postMDProcessor func(site pubmodel.Site, dom *goquery.Document) error + +func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error { + siteURL, err := url.Parse(site.BaseURL) + if err != nil { + return err + } + + dom.Find("img").Each(func(i int, s *goquery.Selection) { + srcUrl := s.AttrOr("src", "") + if site.BaseURL == "" { + return + } else if strings.HasPrefix(srcUrl, "http:") || strings.HasPrefix(srcUrl, "https:") { + return + } + + pu, err := url.Parse(srcUrl) + if err != nil { + return + } + + absURL := siteURL.ResolveReference(pu) + + s.SetAttr("src", absURL.String()) + }) + return nil +} diff --git a/providers/sitebuilder/tmplfns.go b/providers/sitebuilder/tmplfns.go index 4b69a57..c6adc58 100644 --- a/providers/sitebuilder/tmplfns.go +++ b/providers/sitebuilder/tmplfns.go @@ -3,7 +3,7 @@ package sitebuilder import ( "html/template" "net/url" - "path/filepath" + "strings" "time" "lmika.dev/lmika/weiro/models/pubmodel" @@ -20,7 +20,7 @@ func templateFns(site pubmodel.Site, opts Options) template.FuncMap { if err != nil { return "", err } - pu.Path = filepath.Join(pu.Path, basePath) + pu.Path = joinPath(pu.Path, basePath) return pu.String(), nil }, "format_date": func(date time.Time) string { @@ -32,3 +32,7 @@ func templateFns(site pubmodel.Site, opts Options) template.FuncMap { }, } } + +func joinPath(basePath, path string) string { + return strings.TrimSuffix(basePath, "/") + "/" + strings.TrimPrefix(path, "/") +} From 0bd91de234f11a0ab265c822651aa79cd5f085c4 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 9 Mar 2026 21:47:02 +1100 Subject: [PATCH 08/62] Added a site setting section --- cmds/server.go | 4 + handlers/posts.go | 12 - handlers/sitesettings.go | 52 +++ models/sites.go | 5 +- providers/db/gen/sqlgen/models.go | 1 + providers/db/gen/sqlgen/sites.sql.go | 35 +- providers/db/sites.go | 22 +- services/posts/create.go | 11 +- services/publisher/service.go | 7 + services/sites/services.go | 38 +- services/sites/tzones.go | 23 + services/sites/tzones.txt | 606 +++++++++++++++++++++++++++ sql/queries/sites.sql | 6 +- sql/schema/03_add_loc_to_site.up.sql | 1 + views/_common/nav.html | 3 + views/posts/index.html | 3 - views/sitesettings/general.html | 63 +++ 17 files changed, 856 insertions(+), 36 deletions(-) create mode 100644 handlers/sitesettings.go create mode 100644 services/sites/tzones.go create mode 100644 services/sites/tzones.txt create mode 100644 sql/schema/03_add_loc_to_site.up.sql create mode 100644 views/sitesettings/general.html diff --git a/cmds/server.go b/cmds/server.go index 2445b8a..40c2690 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -111,6 +111,7 @@ Starting weiro without any arguments will start the server. lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} ph := handlers.PostsHandler{PostService: svcs.Posts} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} + ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -137,6 +138,9 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete) siteGroup.Delete("/uploads/:uploadID", uh.Delete) + siteGroup.Get("/settings", ssh.General) + siteGroup.Post("/settings", ssh.UpdateGeneral) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) app.Get("/first-run", ih.FirstRun) app.Post("/first-run", ih.FirstRunSubmit) diff --git a/handlers/posts.go b/handlers/posts.go index 283ffd9..3f282e0 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -152,15 +152,3 @@ func (ph PostsHandler) Delete(c fiber.Ctx) error { return c.Redirect().To("/") })) } - -func (ph PostsHandler) Rebuild(c fiber.Ctx) error { - if err := ph.PostService.RebuildSite(c.Context()); err != nil { - return err - } - - return accepts(c, json(func() any { - return fiber.Map{} - }), html(func(c fiber.Ctx) error { - return c.Redirect().To("/") - })) -} diff --git a/handlers/sitesettings.go b/handlers/sitesettings.go new file mode 100644 index 0000000..0fe2100 --- /dev/null +++ b/handlers/sitesettings.go @@ -0,0 +1,52 @@ +package handlers + +import ( + "fmt" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/sites" +) + +type SiteSettingsHandler struct { + SiteService *sites.Service +} + +func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error { + site := ctx.Locals("site").(models.Site) + + return ctx.Render("sitesettings/general", fiber.Map{ + "site": site, + "tzones": sites.ListZones(), + }) +} + +func (s *SiteSettingsHandler) UpdateGeneral(c fiber.Ctx) error { + site := c.Locals("site").(models.Site) + + var params sites.UpdateSiteSettingsParams + if err := c.Bind().Body(¶ms); err != nil { + return err + } + params.SiteID = site.ID + + if _, err := s.SiteService.UpdateSiteSettings(c.Context(), params); err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/settings", +site.ID)) +} + +func (ph PostsHandler) Rebuild(c fiber.Ctx) error { + site := c.Locals("site").(models.Site) + + if err := ph.PostService.RebuildSite(c.Context()); err != nil { + return err + } + + return accepts(c, json(func() any { + return fiber.Map{} + }), html(func(c fiber.Ctx) error { + return c.Redirect().To(fmt.Sprintf("/sites/%v/settings", +site.ID)) + })) +} diff --git a/models/sites.go b/models/sites.go index 42b8a3c..16cbef4 100644 --- a/models/sites.go +++ b/models/sites.go @@ -27,8 +27,9 @@ type Site struct { GUID string Created time.Time - Title string - Tagline string + Title string + Tagline string + Timezone string } type SitePublishTarget struct { diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 1de5ce3..4f69bd0 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -47,6 +47,7 @@ type Site struct { Title string Tagline string CreatedAt int64 + Timezone string } type Upload struct { diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index fd3d3c6..1a1b965 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -27,8 +27,9 @@ INSERT INTO sites ( guid, title, tagline, + timezone, created_at -) VALUES (?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?) RETURNING id ` @@ -37,6 +38,7 @@ type InsertSiteParams struct { Guid string Title string Tagline string + Timezone string CreatedAt int64 } @@ -46,6 +48,7 @@ func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, arg.Guid, arg.Title, arg.Tagline, + arg.Timezone, arg.CreatedAt, ) var id int64 @@ -98,7 +101,7 @@ func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSite } const selectSiteByGUID = `-- name: SelectSiteByGUID :one -SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE guid = ? +SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE guid = ? ` func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) { @@ -111,12 +114,13 @@ func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, erro &i.Title, &i.Tagline, &i.CreatedAt, + &i.Timezone, ) return i, err } const selectSiteByID = `-- name: SelectSiteByID :one -SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE id = ? +SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE id = ? ` func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { @@ -129,12 +133,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { &i.Title, &i.Tagline, &i.CreatedAt, + &i.Timezone, ) return i, err } const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many -SELECT id, owner_id, guid, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC +SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE owner_id = ? ORDER BY title ASC ` func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) { @@ -153,6 +158,7 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] &i.Title, &i.Tagline, &i.CreatedAt, + &i.Timezone, ); err != nil { return nil, err } @@ -166,3 +172,24 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] } return items, nil } + +const updateSite = `-- name: UpdateSite :exec +UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ? +` + +type UpdateSiteParams struct { + Title string + Tagline string + Timezone string + ID int64 +} + +func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error { + _, err := q.db.ExecContext(ctx, updateSite, + arg.Title, + arg.Tagline, + arg.Timezone, + arg.ID, + ) + return err +} diff --git a/providers/db/sites.go b/providers/db/sites.go index f878e45..28d83f6 100644 --- a/providers/db/sites.go +++ b/providers/db/sites.go @@ -46,6 +46,7 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { Guid: site.GUID, Title: site.Title, Tagline: site.Tagline, + Timezone: site.Timezone, CreatedAt: timeToInt(site.Created), }) if err != nil { @@ -55,8 +56,12 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { return nil } - // No update query defined in sqlgen yet - return nil + return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + ID: site.ID, + }) } func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) { @@ -96,11 +101,12 @@ func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwn func dbSiteToSite(row sqlgen.Site) models.Site { return models.Site{ - ID: row.ID, - OwnerID: row.OwnerID, - GUID: row.Guid, - Title: row.Title, - Tagline: row.Tagline, - Created: time.Unix(row.CreatedAt, 0).UTC(), + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Timezone: row.Timezone, + Tagline: row.Tagline, + Created: time.Unix(row.CreatedAt, 0).UTC(), } } diff --git a/services/posts/create.go b/services/posts/create.go index 52b9400..f73d49c 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -32,13 +32,20 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod post.Title = params.Title post.Body = params.Body post.UpdatedAt = now - post.Slug = post.BestSlug() oldState := post.State switch strings.ToLower(params.Action) { case "publish": post.State = models.StatePublished - post.PublishedAt = now + + // Set the published at with the site timezone, and reset the slug, so that the date + // is in the site timezone. + renderTZ, err := time.LoadLocation(site.Timezone) + if err != nil { + renderTZ = time.UTC + } + post.PublishedAt = now.In(renderTZ) + post.Slug = post.BestSlug() case "save draft": post.State = models.StateDraft post.PublishedAt = time.Time{} diff --git a/services/publisher/service.go b/services/publisher/service.go index ec9834e..f0b39f0 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -6,6 +6,7 @@ import ( "iter" "log" "os" + "time" "emperror.dev/errors" "github.com/go-openapi/runtime" @@ -71,10 +72,16 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { } func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, target models.SitePublishTarget) error { + renderTZ, err := time.LoadLocation(pubSite.Timezone) + if err != nil { + renderTZ = time.UTC + } + sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ BasePosts: "/posts", TemplatesFS: simplecss.FS, FeedItems: 30, + RenderTZ: renderTZ, }) if err != nil { return err diff --git a/services/sites/services.go b/services/sites/services.go index 22e3916..06afe15 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -77,10 +77,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo } newSite = models.Site{ - Title: defaultIfEmpty(req.SiteName, "New Site"), - GUID: models.NewNanoID(), - OwnerID: newUser.ID, - Created: time.Now(), + Title: defaultIfEmpty(req.SiteName, "New Site"), + GUID: models.NewNanoID(), + OwnerID: newUser.ID, + Timezone: "UTC", + Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { return newUser, newSite, err @@ -126,3 +127,32 @@ func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, e func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwner, error) { return s.db.SelectAllSitesWithOwners(ctx) } + +type UpdateSiteSettingsParams struct { + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` +} + +func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { + site, err := s.GetSiteByID(ctx, params.SiteID) + if err != nil { + return models.Site{}, err + } + + _, err = time.LoadLocation(params.Timezone) + if err != nil { + return models.Site{}, errors.Wrap(err, "invalid timezone") + } + + site.Title = params.Name + site.Tagline = params.Tagline + site.Timezone = params.Timezone + + if err := s.db.SaveSite(ctx, &site); err != nil { + return models.Site{}, err + } + + return site, nil +} diff --git a/services/sites/tzones.go b/services/sites/tzones.go new file mode 100644 index 0000000..a61f208 --- /dev/null +++ b/services/sites/tzones.go @@ -0,0 +1,23 @@ +package sites + +import ( + "embed" + "strings" + "sync" +) + +//go:embed tzones.txt +var tzonesFS embed.FS + +var loadZones = sync.OnceValue(func() []string { + zones, err := tzonesFS.ReadFile("tzones.txt") + if err != nil { + return nil + } + + return strings.Split(string(zones), "\n") +}) + +func ListZones() []string { + return loadZones() +} diff --git a/services/sites/tzones.txt b/services/sites/tzones.txt new file mode 100644 index 0000000..1e65bff --- /dev/null +++ b/services/sites/tzones.txt @@ -0,0 +1,606 @@ +Africa/Abidjan +Africa/Accra +Africa/Addis_Ababa +Africa/Algiers +Africa/Asmara +Africa/Asmera +Africa/Bamako +Africa/Bangui +Africa/Banjul +Africa/Bissau +Africa/Blantyre +Africa/Brazzaville +Africa/Bujumbura +Africa/Cairo +Africa/Casablanca +Africa/Ceuta +Africa/Conakry +Africa/Dakar +Africa/Dar_es_Salaam +Africa/Djibouti +Africa/Douala +Africa/El_Aaiun +Africa/Freetown +Africa/Gaborone +Africa/Harare +Africa/Johannesburg +Africa/Juba +Africa/Kampala +Africa/Khartoum +Africa/Kigali +Africa/Kinshasa +Africa/Lagos +Africa/Libreville +Africa/Lome +Africa/Luanda +Africa/Lubumbashi +Africa/Lusaka +Africa/Malabo +Africa/Maputo +Africa/Maseru +Africa/Mbabane +Africa/Mogadishu +Africa/Monrovia +Africa/Nairobi +Africa/Ndjamena +Africa/Niamey +Africa/Nouakchott +Africa/Ouagadougou +Africa/Porto-Novo +Africa/Sao_Tome +Africa/Timbuktu +Africa/Tripoli +Africa/Tunis +Africa/Windhoek +America/Adak +America/Anchorage +America/Anguilla +America/Antigua +America/Araguaina +America/Argentina/Buenos_Aires +America/Argentina/Catamarca +America/Argentina/ComodRivadavia +America/Argentina/Cordoba +America/Argentina/Jujuy +America/Argentina/La_Rioja +America/Argentina/Mendoza +America/Argentina/Rio_Gallegos +America/Argentina/Salta +America/Argentina/San_Juan +America/Argentina/San_Luis +America/Argentina/Tucuman +America/Argentina/Ushuaia +America/Aruba +America/Asuncion +America/Atikokan +America/Atka +America/Bahia +America/Bahia_Banderas +America/Barbados +America/Belem +America/Belize +America/Blanc-Sablon +America/Boa_Vista +America/Bogota +America/Boise +America/Buenos_Aires +America/Cambridge_Bay +America/Campo_Grande +America/Cancun +America/Caracas +America/Catamarca +America/Cayenne +America/Cayman +America/Chicago +America/Chihuahua +America/Coral_Harbour +America/Cordoba +America/Costa_Rica +America/Creston +America/Cuiaba +America/Curacao +America/Danmarkshavn +America/Dawson +America/Dawson_Creek +America/Denver +America/Detroit +America/Dominica +America/Edmonton +America/Eirunepe +America/El_Salvador +America/Ensenada +America/Fort_Nelson +America/Fort_Wayne +America/Fortaleza +America/Glace_Bay +America/Godthab +America/Goose_Bay +America/Grand_Turk +America/Grenada +America/Guadeloupe +America/Guatemala +America/Guayaquil +America/Guyana +America/Halifax +America/Havana +America/Hermosillo +America/Indiana/Indianapolis +America/Indiana/Knox +America/Indiana/Marengo +America/Indiana/Petersburg +America/Indiana/Tell_City +America/Indiana/Vevay +America/Indiana/Vincennes +America/Indiana/Winamac +America/Indianapolis +America/Inuvik +America/Iqaluit +America/Jamaica +America/Jujuy +America/Juneau +America/Kentucky/Louisville +America/Kentucky/Monticello +America/Knox_IN +America/Kralendijk +America/La_Paz +America/Lima +America/Los_Angeles +America/Louisville +America/Lower_Princes +America/Maceio +America/Managua +America/Manaus +America/Marigot +America/Martinique +America/Matamoros +America/Mazatlan +America/Mendoza +America/Menominee +America/Merida +America/Metlakatla +America/Mexico_City +America/Miquelon +America/Moncton +America/Monterrey +America/Montevideo +America/Montreal +America/Montserrat +America/Nassau +America/New_York +America/Nipigon +America/Nome +America/Noronha +America/North_Dakota/Beulah +America/North_Dakota/Center +America/North_Dakota/New_Salem +America/Ojinaga +America/Panama +America/Pangnirtung +America/Paramaribo +America/Phoenix +America/Port-au-Prince +America/Port_of_Spain +America/Porto_Acre +America/Porto_Velho +America/Puerto_Rico +America/Punta_Arenas +America/Rainy_River +America/Rankin_Inlet +America/Recife +America/Regina +America/Resolute +America/Rio_Branco +America/Rosario +America/Santa_Isabel +America/Santarem +America/Santiago +America/Santo_Domingo +America/Sao_Paulo +America/Scoresbysund +America/Shiprock +America/Sitka +America/St_Barthelemy +America/St_Johns +America/St_Kitts +America/St_Lucia +America/St_Thomas +America/St_Vincent +America/Swift_Current +America/Tegucigalpa +America/Thule +America/Thunder_Bay +America/Tijuana +America/Toronto +America/Tortola +America/Vancouver +America/Virgin +America/Whitehorse +America/Winnipeg +America/Yakutat +America/Yellowknife +Antarctica/Casey +Antarctica/Davis +Antarctica/DumontDUrville +Antarctica/Macquarie +Antarctica/Mawson +Antarctica/McMurdo +Antarctica/Palmer +Antarctica/Rothera +Antarctica/South_Pole +Antarctica/Syowa +Antarctica/Troll +Antarctica/Vostok +Arctic/Longyearbyen +Asia/Aden +Asia/Almaty +Asia/Amman +Asia/Anadyr +Asia/Aqtau +Asia/Aqtobe +Asia/Ashgabat +Asia/Ashkhabad +Asia/Atyrau +Asia/Baghdad +Asia/Bahrain +Asia/Baku +Asia/Bangkok +Asia/Barnaul +Asia/Beirut +Asia/Bishkek +Asia/Brunei +Asia/Calcutta +Asia/Chita +Asia/Choibalsan +Asia/Chongqing +Asia/Chungking +Asia/Colombo +Asia/Dacca +Asia/Damascus +Asia/Dhaka +Asia/Dili +Asia/Dubai +Asia/Dushanbe +Asia/Famagusta +Asia/Gaza +Asia/Harbin +Asia/Hebron +Asia/Ho_Chi_Minh +Asia/Hong_Kong +Asia/Hovd +Asia/Irkutsk +Asia/Istanbul +Asia/Jakarta +Asia/Jayapura +Asia/Jerusalem +Asia/Kabul +Asia/Kamchatka +Asia/Karachi +Asia/Kashgar +Asia/Kathmandu +Asia/Katmandu +Asia/Khandyga +Asia/Kolkata +Asia/Krasnoyarsk +Asia/Kuala_Lumpur +Asia/Kuching +Asia/Kuwait +Asia/Macao +Asia/Macau +Asia/Magadan +Asia/Makassar +Asia/Manila +Asia/Muscat +Asia/Nicosia +Asia/Novokuznetsk +Asia/Novosibirsk +Asia/Omsk +Asia/Oral +Asia/Phnom_Penh +Asia/Pontianak +Asia/Pyongyang +Asia/Qatar +Asia/Qyzylorda +Asia/Rangoon +Asia/Riyadh +Asia/Saigon +Asia/Sakhalin +Asia/Samarkand +Asia/Seoul +Asia/Shanghai +Asia/Singapore +Asia/Srednekolymsk +Asia/Taipei +Asia/Tashkent +Asia/Tbilisi +Asia/Tehran +Asia/Tel_Aviv +Asia/Thimbu +Asia/Thimphu +Asia/Tokyo +Asia/Tomsk +Asia/Ujung_Pandang +Asia/Ulaanbaatar +Asia/Ulan_Bator +Asia/Urumqi +Asia/Ust-Nera +Asia/Vientiane +Asia/Vladivostok +Asia/Yakutsk +Asia/Yangon +Asia/Yekaterinburg +Asia/Yerevan +Atlantic/Azores +Atlantic/Bermuda +Atlantic/Canary +Atlantic/Cape_Verde +Atlantic/Faeroe +Atlantic/Faroe +Atlantic/Jan_Mayen +Atlantic/Madeira +Atlantic/Reykjavik +Atlantic/South_Georgia +Atlantic/St_Helena +Atlantic/Stanley +Australia/ACT +Australia/Adelaide +Australia/Brisbane +Australia/Broken_Hill +Australia/Canberra +Australia/Currie +Australia/Darwin +Australia/Eucla +Australia/Hobart +Australia/LHI +Australia/Lindeman +Australia/Lord_Howe +Australia/Melbourne +Australia/NSW +Australia/North +Australia/Perth +Australia/Queensland +Australia/South +Australia/Sydney +Australia/Tasmania +Australia/Victoria +Australia/West +Australia/Yancowinna +Brazil/Acre +Brazil/DeNoronha +Brazil/East +Brazil/West +CET +CST6CDT +Canada/Atlantic +Canada/Central +Canada/Eastern +Canada/Mountain +Canada/Newfoundland +Canada/Pacific +Canada/Saskatchewan +Canada/Yukon +Chile/Continental +Chile/EasterIsland +Cuba +EET +EST +EST5EDT +Egypt +Eire +Etc/GMT +Etc/GMT+0 +Etc/GMT+1 +Etc/GMT+10 +Etc/GMT+11 +Etc/GMT+12 +Etc/GMT+2 +Etc/GMT+3 +Etc/GMT+4 +Etc/GMT+5 +Etc/GMT+6 +Etc/GMT+7 +Etc/GMT+8 +Etc/GMT+9 +Etc/GMT-0 +Etc/GMT-1 +Etc/GMT-10 +Etc/GMT-11 +Etc/GMT-12 +Etc/GMT-13 +Etc/GMT-14 +Etc/GMT-2 +Etc/GMT-3 +Etc/GMT-4 +Etc/GMT-5 +Etc/GMT-6 +Etc/GMT-7 +Etc/GMT-8 +Etc/GMT-9 +Etc/GMT0 +Etc/Greenwich +Etc/UCT +Etc/UTC +Etc/Universal +Etc/Zulu +Europe/Amsterdam +Europe/Andorra +Europe/Astrakhan +Europe/Athens +Europe/Belfast +Europe/Belgrade +Europe/Berlin +Europe/Bratislava +Europe/Brussels +Europe/Bucharest +Europe/Budapest +Europe/Busingen +Europe/Chisinau +Europe/Copenhagen +Europe/Dublin +Europe/Gibraltar +Europe/Guernsey +Europe/Helsinki +Europe/Isle_of_Man +Europe/Istanbul +Europe/Jersey +Europe/Kaliningrad +Europe/Kiev +Europe/Kirov +Europe/Lisbon +Europe/Ljubljana +Europe/London +Europe/Luxembourg +Europe/Madrid +Europe/Malta +Europe/Mariehamn +Europe/Minsk +Europe/Monaco +Europe/Moscow +Europe/Nicosia +Europe/Oslo +Europe/Paris +Europe/Podgorica +Europe/Prague +Europe/Riga +Europe/Rome +Europe/Samara +Europe/San_Marino +Europe/Sarajevo +Europe/Saratov +Europe/Simferopol +Europe/Skopje +Europe/Sofia +Europe/Stockholm +Europe/Tallinn +Europe/Tirane +Europe/Tiraspol +Europe/Ulyanovsk +Europe/Uzhgorod +Europe/Vaduz +Europe/Vatican +Europe/Vienna +Europe/Vilnius +Europe/Volgograd +Europe/Warsaw +Europe/Zagreb +Europe/Zaporozhye +Europe/Zurich +Factory +GB +GB-Eire +GMT +GMT+0 +GMT-0 +GMT0 +Greenwich +HST +Hongkong +Iceland +Indian/Antananarivo +Indian/Chagos +Indian/Christmas +Indian/Cocos +Indian/Comoro +Indian/Kerguelen +Indian/Mahe +Indian/Maldives +Indian/Mauritius +Indian/Mayotte +Indian/Reunion +Iran +Israel +Jamaica +Japan +Kwajalein +Libya +MET +MST +MST7MDT +Mexico/BajaNorte +Mexico/BajaSur +Mexico/General +NZ +NZ-CHAT +Navajo +PRC +PST8PDT +Pacific/Apia +Pacific/Auckland +Pacific/Bougainville +Pacific/Chatham +Pacific/Chuuk +Pacific/Easter +Pacific/Efate +Pacific/Enderbury +Pacific/Fakaofo +Pacific/Fiji +Pacific/Funafuti +Pacific/Galapagos +Pacific/Gambier +Pacific/Guadalcanal +Pacific/Guam +Pacific/Honolulu +Pacific/Johnston +Pacific/Kiritimati +Pacific/Kosrae +Pacific/Kwajalein +Pacific/Majuro +Pacific/Marquesas +Pacific/Midway +Pacific/Nauru +Pacific/Niue +Pacific/Norfolk +Pacific/Noumea +Pacific/Pago_Pago +Pacific/Palau +Pacific/Pitcairn +Pacific/Pohnpei +Pacific/Ponape +Pacific/Port_Moresby +Pacific/Rarotonga +Pacific/Saipan +Pacific/Samoa +Pacific/Tahiti +Pacific/Tarawa +Pacific/Tongatapu +Pacific/Truk +Pacific/Wake +Pacific/Wallis +Pacific/Yap +Poland +Portugal +ROC +ROK +Singapore +SystemV/AST4 +SystemV/AST4ADT +SystemV/CST6 +SystemV/CST6CDT +SystemV/EST5 +SystemV/EST5EDT +SystemV/HST10 +SystemV/MST7 +SystemV/MST7MDT +SystemV/PST8 +SystemV/PST8PDT +SystemV/YST9 +SystemV/YST9YDT +Turkey +UCT +US/Alaska +US/Aleutian +US/Arizona +US/Central +US/East-Indiana +US/Eastern +US/Hawaii +US/Indiana-Starke +US/Michigan +US/Mountain +US/Pacific +US/Pacific-New +US/Samoa +UTC +Universal +W-SU +WET +Zulu diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index 92e7ccb..8fe2469 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -13,13 +13,17 @@ INSERT INTO sites ( guid, title, tagline, + timezone, created_at -) VALUES (?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?) RETURNING id; -- name: HasUsersAndSites :one SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites; +-- name: UpdateSite :exec +UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ?; + -- name: SelectAllSitesWithOwners :many SELECT s.id, s.guid, s.title, s.owner_id, u.username FROM sites s diff --git a/sql/schema/03_add_loc_to_site.up.sql b/sql/schema/03_add_loc_to_site.up.sql new file mode 100644 index 0000000..2798610 --- /dev/null +++ b/sql/schema/03_add_loc_to_site.up.sql @@ -0,0 +1 @@ +ALTER TABLE sites ADD COLUMN timezone TEXT NOT NULL DEFAULT 'UTC'; \ No newline at end of file diff --git a/views/_common/nav.html b/views/_common/nav.html index 7b8bd16..87801d2 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -13,6 +13,9 @@ + +
\ No newline at end of file From 847e8e76d063ea4be36175fb7677f70e55b9917c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:11:18 +1100 Subject: [PATCH 09/62] Add categories feature design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-18-categories-design.md | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-18-categories-design.md diff --git a/docs/superpowers/specs/2026-03-18-categories-design.md b/docs/superpowers/specs/2026-03-18-categories-design.md new file mode 100644 index 0000000..eb5a004 --- /dev/null +++ b/docs/superpowers/specs/2026-03-18-categories-design.md @@ -0,0 +1,157 @@ +# Categories Feature Design + +## Overview + +Add flat, many-to-many categories to Weiro. Categories are managed via a dedicated admin page and assigned to posts on the post edit form. On the published static site, categories appear as labels on posts, archive pages per category, a category index page, and per-category RSS/JSON feeds. Categories with no published posts are hidden from the published site. + +## Data Model + +### New Tables (migration `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, + 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); +``` + +### New Go Model (`models/categories.go`) + +```go +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"` +} +``` + +- `slug` is auto-generated from `name` (e.g. "Go Programming" -> `go-programming`), editable by the user. +- `description` is Markdown, rendered on the category archive page. Defaults to empty string. + +## Admin UI + +### Category Management Page + +Route: `/sites/:siteID/categories` + +- Lists all categories for the site showing name, slug, and post count. +- "New category" button navigates to a create/edit form. +- Edit form fields: Name, Slug (auto-generated but editable), Description (Markdown textarea). +- Delete button with confirmation. Deletes the category and its post associations; does not delete the posts. + +Handler: `CategoriesHandler` (new, in `handlers/categories.go`). +Templates: `views/categories/index.html`, `views/categories/edit.html`. + +### Post Edit Form Changes + +- A multi-select checkbox list of all available categories, displayed in a **right sidebar** alongside the main title/body editing area on the left. +- Selected category IDs sent with the form submission. +- `CreatePostParams` gains `CategoryIDs []int64`. + +### Post List (Admin) + +- Category names shown as small labels next to each post title. + +## Static Site Output + +### Category Index Page (`/categories/`) + +Lists all categories that have at least one published post. For each category: + +- Category name as a clickable link to the archive page +- Post count +- First sentence/line of the description as a brief excerpt + +### Category Archive Pages (`/categories//`) + +- Category name as heading +- Full Markdown description rendered below the heading +- List of published posts in the category, ordered by `published_at` descending + +### Post Pages + +Each post page displays its category names as clickable links to the corresponding category archive pages. + +### Feeds + +Per-category feeds: +- `/categories//feed.xml` (RSS) +- `/categories//feed.json` (JSON Feed) + +Main site feeds (`/feed.xml`, `/feed.json`) gain category metadata on each post entry. + +### Empty Category Handling + +Categories with no published posts are hidden from the published site: no index entry, no archive page, no feed generated. They remain visible and manageable in the admin UI. + +## SQL Queries + +New file: `sql/queries/categories.sql` + +- `SelectCategoriesOfSite` — all categories for a site, ordered by name +- `SelectCategory` — single category by ID +- `SelectCategoryByGUID` — single category by GUID +- `SelectCategoriesOfPost` — categories for a given post (via join table) +- `SelectPostsOfCategory` — published, non-deleted posts in a category, ordered by `published_at` desc +- `CountPostsOfCategory` — count of published posts per category +- `InsertCategory` / `UpdateCategory` / `DeleteCategory` — CRUD +- `InsertPostCategory` / `DeletePostCategory` — manage the join table +- `DeletePostCategoriesByPost` — clear all categories for a post (delete-then-reinsert on save) + +## Service Layer + +### New `services/categories` Package + +`Service` struct with methods: + +- `ListCategories(ctx) ([]Category, error)` — all categories for the current site (from context) +- `GetCategory(ctx, id) (*Category, error)` +- `CreateCategory(ctx, params) (*Category, error)` — auto-generates slug from name +- `UpdateCategory(ctx, params) (*Category, error)` +- `DeleteCategory(ctx, id) error` — deletes category and post associations, queues site rebuild + +### Changes to `services/posts` + +- `UpdatePost` — after saving the post, deletes existing `post_categories` rows and re-inserts for the selected category IDs +- `GetPost` / `ListPosts` — loads each post's categories for admin display + +### Changes to Publishing Pipeline + +- `pubmodel.Site` gains new fields: + - Category list (with post counts and description excerpts for the index page) + - A function to iterate published posts by category +- `sitebuilder.Builder.BuildSite` gains additional goroutines for: + - Rendering the category index page + - Rendering each category archive page + - Rendering per-category feeds +- New templates: `tmplNameCategoryList`, `tmplNameCategorySingle` + +### Rebuild Triggers + +Saving or deleting a category queues a site rebuild, same as post state changes. + +## DB Provider + +`providers/db/` gains wrapper methods for all new sqlc queries, following the same pattern as existing post methods (e.g. `SaveCategory`, `SelectCategoriesOfPost`, etc.). From 9a02a2f8af6bead75cb7fd04e1528fb11dee4a6f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:14:24 +1100 Subject: [PATCH 10/62] Address spec review feedback for categories design Adds updated_at field, transaction requirement, slug collision handling, authorization checks, explicit query filters, pubmodel signatures, and template registration notes. Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-18-categories-design.md | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-03-18-categories-design.md b/docs/superpowers/specs/2026-03-18-categories-design.md index eb5a004..9c10abb 100644 --- a/docs/superpowers/specs/2026-03-18-categories-design.md +++ b/docs/superpowers/specs/2026-03-18-categories-design.md @@ -17,6 +17,7 @@ CREATE TABLE categories ( 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); @@ -44,11 +45,13 @@ type Category struct { Slug string `json:"slug"` Description string `json:"description"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } ``` - `slug` is auto-generated from `name` (e.g. "Go Programming" -> `go-programming`), editable by the user. - `description` is Markdown, rendered on the category archive page. Defaults to empty string. +- DB provider must use the existing `timeToInt()`/`time.Unix()` helpers for timestamp conversion, consistent with how posts are handled. ## Admin UI @@ -66,7 +69,7 @@ Templates: `views/categories/index.html`, `views/categories/edit.html`. ### Post Edit Form Changes -- A multi-select checkbox list of all available categories, displayed in a **right sidebar** alongside the main title/body editing area on the left. +- A multi-select checkbox list of all available categories (sorted alphabetically by name), displayed in a **right sidebar** alongside the main title/body editing area on the left. - Selected category IDs sent with the form submission. - `CreatePostParams` gains `CategoryIDs []int64`. @@ -114,8 +117,8 @@ New file: `sql/queries/categories.sql` - `SelectCategory` — single category by ID - `SelectCategoryByGUID` — single category by GUID - `SelectCategoriesOfPost` — categories for a given post (via join table) -- `SelectPostsOfCategory` — published, non-deleted posts in a category, ordered by `published_at` desc -- `CountPostsOfCategory` — count of published posts per category +- `SelectPostsOfCategory` — published, non-deleted posts in a category (`state = 0 AND deleted_at = 0`), ordered by `published_at` desc +- `CountPostsOfCategory` — count of published posts per category (same `state = 0 AND deleted_at = 0` filter) - `InsertCategory` / `UpdateCategory` / `DeleteCategory` — CRUD - `InsertPostCategory` / `DeletePostCategory` — manage the join table - `DeletePostCategoriesByPost` — clear all categories for a post (delete-then-reinsert on save) @@ -128,25 +131,28 @@ New file: `sql/queries/categories.sql` - `ListCategories(ctx) ([]Category, error)` — all categories for the current site (from context) - `GetCategory(ctx, id) (*Category, error)` -- `CreateCategory(ctx, params) (*Category, error)` — auto-generates slug from name -- `UpdateCategory(ctx, params) (*Category, error)` +- `CreateCategory(ctx, params) (*Category, error)` — auto-generates slug from name. If the slug collides with an existing one for the same site, return a validation error. +- `UpdateCategory(ctx, params) (*Category, error)` — same slug collision check on update. - `DeleteCategory(ctx, id) error` — deletes category and post associations, queues site rebuild +All mutation methods verify site ownership (same pattern as post service authorization checks). + ### Changes to `services/posts` -- `UpdatePost` — after saving the post, deletes existing `post_categories` rows and re-inserts for the selected category IDs +- `UpdatePost` — after saving the post, deletes existing `post_categories` rows and re-inserts for the selected category IDs. The post save and category reassignment must run within a single database transaction to ensure atomicity. - `GetPost` / `ListPosts` — loads each post's categories for admin display ### Changes to Publishing Pipeline - `pubmodel.Site` gains new fields: - - Category list (with post counts and description excerpts for the index page) - - A function to iterate published posts by category + - `Categories []CategoryWithCount` — category list with post counts and description excerpts for the index page + - `PostIterByCategory func(ctx context.Context, categoryID int64) iter.Seq[models.Maybe[*models.Post]]` — iterator for posts in a specific category - `sitebuilder.Builder.BuildSite` gains additional goroutines for: - Rendering the category index page - Rendering each category archive page - Rendering per-category feeds -- New templates: `tmplNameCategoryList`, `tmplNameCategorySingle` +- New templates: `tmplNameCategoryList`, `tmplNameCategorySingle` (must be added to the `ParseFS` call in `sitebuilder.New()`) +- `postSingleData` gains a `Categories []Category` field so post templates can render category links ### Rebuild Triggers @@ -155,3 +161,9 @@ Saving or deleting a category queues a site rebuild, same as post state changes. ## DB Provider `providers/db/` gains wrapper methods for all new sqlc queries, following the same pattern as existing post methods (e.g. `SaveCategory`, `SelectCategoriesOfPost`, etc.). + +## Design Decisions + +- **Hard delete for categories** — unlike posts which use soft-delete, categories are hard-deleted. They are simpler entities and don't need a trash/restore workflow. +- **No sort_order column** — categories are sorted alphabetically by name. Manual ordering can be added later if needed. +- **Existing microblog-crosspost feed** — kept as-is. Per-category feeds are a separate, additive feature. From 41c8d1e2f5d6e70b4e454e6b2fb6f210484c03ee Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:29:11 +1100 Subject: [PATCH 11/62] Add categories implementation plan 9-task plan covering migration, sqlc queries, DB provider, service layer, admin UI, post form integration, site builder with category pages and per-category feeds. Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-18-categories.md | 2036 +++++++++++++++++ 1 file changed, 2036 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-18-categories.md diff --git a/docs/superpowers/plans/2026-03-18-categories.md b/docs/superpowers/plans/2026-03-18-categories.md new file mode 100644 index 0000000..b4f3932 --- /dev/null +++ b/docs/superpowers/plans/2026-03-18-categories.md @@ -0,0 +1,2036 @@ +# 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 +
+
+

Categories

+ +
+ + + + + + + + + + + + {{ range .categories }} + + + + + + + {{ else }} + + + + {{ end }} + +
NameSlugPosts
{{ .Name }}{{ .Slug }}{{ .PostCount }} + Edit +
No categories yet.
+
+``` + +Create `views/categories/edit.html`: + +```html +
+
+

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
+ + {{ if .isNew }} +
+ {{ else }} + + {{ end }} + +
+ +
+ +
+
+
+ +
+ +
Auto-generated from name if left blank.
+
+
+
+ +
+ +
Markdown supported. Displayed on the category archive page.
+
+
+
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+``` + +- [ ] **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 + +``` + +- [ ] **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 }} +
+
+
+
+ +
+ +
+
+ +
+
+ {{ if $isPublished }} + + {{ else }} + + + {{ end }} +
+
+
+
+
Categories
+
+ {{ range .categories }} +
+ + +
+ {{ else }} + No categories yet. + {{ end }} +
+
+
+
+
+
+``` + +- [ ] **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 +
+ {{ if eq .State 1 }} + {{ $.user.FormatTime .UpdatedAt }} Draft + {{ else }} + {{ $.user.FormatTime .PublishedAt }} + {{ end }} + {{ range .Categories }} + {{ .Name }} + {{ end }} +
+``` + +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 +

Categories

+
    +{{ range .Categories }} +
  • + {{ .Name }} ({{ .PostCount }}) + {{ if .DescriptionBrief }}
    {{ .DescriptionBrief }}{{ end }} +
  • +{{ end }} +
+``` + +Create `layouts/simplecss/categories_single.html`: + +```html +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} +{{ end }} +``` + +- [ ] **Step 4: Update the post single template to show categories** + +Modify `layouts/simplecss/posts_single.html`: + +```html +{{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} +{{ .HTML }} +{{ format_date .Post.PublishedAt }} +{{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+{{ end }} +``` + +- [ ] **Step 5: Update the post list template to show categories** + +Modify `layouts/simplecss/posts_list.html`: + +```html +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ 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}}{{.Post.Title}},{{ end }}`)}, + "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, + "categories_single.html": {Data: []byte(`

{{.Category.Name}}

`)}, + } + + 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": "

This is a test post

\n", + "2026/02/20/another-post/index.html": "

This is another test post

\n", + "index.html": "Test Post,Another Post,", + } + + 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, , )` with `p.SelectPostsOfSite(ctx, , , 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//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" +``` From 641b402d4a5bdd18998f3ab872511a8b5a5b5c5c Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:32:24 +1100 Subject: [PATCH 12/62] feat: add categories migration and model Co-Authored-By: Claude Sonnet 4.6 --- models/categories.go | 61 +++++++++++++++++++++++++++++++++ models/categories_test.go | 28 +++++++++++++++ models/errors.go | 1 + sql/schema/04_categories.up.sql | 23 +++++++++++++ 4 files changed, 113 insertions(+) create mode 100644 models/categories.go create mode 100644 models/categories_test.go create mode 100644 sql/schema/04_categories.up.sql diff --git a/models/categories.go b/models/categories.go new file mode 100644 index 0000000..5655009 --- /dev/null +++ b/models/categories.go @@ -0,0 +1,61 @@ +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, "-") +} + +// BriefDescription returns the first sentence or line of the description. +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 +} diff --git a/models/categories_test.go b/models/categories_test.go new file mode 100644 index 0000000..facf08b --- /dev/null +++ b/models/categories_test.go @@ -0,0 +1,28 @@ +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)) + }) + } +} diff --git a/models/errors.go b/models/errors.go index 997a952..eda780c 100644 --- a/models/errors.go +++ b/models/errors.go @@ -7,3 +7,4 @@ var PermissionError = errors.New("permission denied") var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") +var SlugConflictError = errors.New("a category with this slug already exists") diff --git a/sql/schema/04_categories.up.sql b/sql/schema/04_categories.up.sql new file mode 100644 index 0000000..260d06b --- /dev/null +++ b/sql/schema/04_categories.up.sql @@ -0,0 +1,23 @@ +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); From d47095a90233066a96ad95215136f8390268b0aa Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:33:55 +1100 Subject: [PATCH 13/62] feat: add sqlc queries for categories --- providers/db/gen/sqlgen/categories.sql.go | 305 ++++++++++++++++++ providers/db/gen/sqlgen/db.go | 2 +- providers/db/gen/sqlgen/models.go | 18 +- .../db/gen/sqlgen/pending_uploads.sql.go | 2 +- providers/db/gen/sqlgen/posts.sql.go | 2 +- providers/db/gen/sqlgen/pubtargets.sql.go | 2 +- providers/db/gen/sqlgen/sites.sql.go | 2 +- providers/db/gen/sqlgen/uploads.sql.go | 2 +- providers/db/gen/sqlgen/users.sql.go | 2 +- sql/queries/categories.sql | 53 +++ 10 files changed, 382 insertions(+), 8 deletions(-) create mode 100644 providers/db/gen/sqlgen/categories.sql.go create mode 100644 sql/queries/categories.sql diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go new file mode 100644 index 0000000..d5bc40d --- /dev/null +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -0,0 +1,305 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: categories.sql + +package sqlgen + +import ( + "context" +) + +const countPostsOfCategory = `-- 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 +` + +func (q *Queries) CountPostsOfCategory(ctx context.Context, categoryID int64) (int64, error) { + row := q.db.QueryRowContext(ctx, countPostsOfCategory, categoryID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteCategory = `-- name: DeleteCategory :exec +DELETE FROM categories WHERE id = ? +` + +func (q *Queries) DeleteCategory(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deleteCategory, id) + return err +} + +const deletePostCategoriesByPost = `-- name: DeletePostCategoriesByPost :exec +DELETE FROM post_categories WHERE post_id = ? +` + +func (q *Queries) DeletePostCategoriesByPost(ctx context.Context, postID int64) error { + _, err := q.db.ExecContext(ctx, deletePostCategoriesByPost, postID) + return err +} + +const insertCategory = `-- name: InsertCategory :one +INSERT INTO categories ( + site_id, guid, name, slug, description, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id +` + +type InsertCategoryParams struct { + SiteID int64 + Guid string + Name string + Slug string + Description string + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertCategory(ctx context.Context, arg InsertCategoryParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertCategory, + arg.SiteID, + arg.Guid, + arg.Name, + arg.Slug, + arg.Description, + arg.CreatedAt, + arg.UpdatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const insertPostCategory = `-- name: InsertPostCategory :exec +INSERT OR IGNORE INTO post_categories (post_id, category_id) VALUES (?, ?) +` + +type InsertPostCategoryParams struct { + PostID int64 + CategoryID int64 +} + +func (q *Queries) InsertPostCategory(ctx context.Context, arg InsertPostCategoryParams) error { + _, err := q.db.ExecContext(ctx, insertPostCategory, arg.PostID, arg.CategoryID) + return err +} + +const selectCategoriesOfPost = `-- name: SelectCategoriesOfPost :many +SELECT c.id, c.site_id, c.guid, c.name, c.slug, c.description, c.created_at, c.updated_at FROM categories c +INNER JOIN post_categories pc ON pc.category_id = c.id +WHERE pc.post_id = ? +ORDER BY c.name ASC +` + +func (q *Queries) SelectCategoriesOfPost(ctx context.Context, postID int64) ([]Category, error) { + rows, err := q.db.QueryContext(ctx, selectCategoriesOfPost, postID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Category + for rows.Next() { + var i Category + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const selectCategoriesOfSite = `-- name: SelectCategoriesOfSite :many +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories +WHERE site_id = ? ORDER BY name ASC +` + +func (q *Queries) SelectCategoriesOfSite(ctx context.Context, siteID int64) ([]Category, error) { + rows, err := q.db.QueryContext(ctx, selectCategoriesOfSite, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Category + for rows.Next() { + var i Category + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const selectCategory = `-- name: SelectCategory :one +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE id = ? LIMIT 1 +` + +func (q *Queries) SelectCategory(ctx context.Context, id int64) (Category, error) { + row := q.db.QueryRowContext(ctx, selectCategory, id) + var i Category + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectCategoryByGUID = `-- name: SelectCategoryByGUID :one +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE guid = ? LIMIT 1 +` + +func (q *Queries) SelectCategoryByGUID(ctx context.Context, guid string) (Category, error) { + row := q.db.QueryRowContext(ctx, selectCategoryByGUID, guid) + var i Category + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectCategoryBySlugAndSite = `-- name: SelectCategoryBySlugAndSite :one +SELECT id, site_id, guid, name, slug, description, created_at, updated_at FROM categories WHERE site_id = ? AND slug = ? LIMIT 1 +` + +type SelectCategoryBySlugAndSiteParams struct { + SiteID int64 + Slug string +} + +func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCategoryBySlugAndSiteParams) (Category, error) { + row := q.db.QueryRowContext(ctx, selectCategoryBySlugAndSite, arg.SiteID, arg.Slug) + var i Category + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Name, + &i.Slug, + &i.Description, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many +SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at 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 ? +` + +type SelectPostsOfCategoryParams struct { + CategoryID int64 + Limit int64 + Offset int64 +} + +func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Post + for rows.Next() { + var i Post + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.State, + &i.Guid, + &i.Title, + &i.Body, + &i.Slug, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublishedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateCategory = `-- name: UpdateCategory :exec +UPDATE categories SET + name = ?, + slug = ?, + description = ?, + updated_at = ? +WHERE id = ? +` + +type UpdateCategoryParams struct { + Name string + Slug string + Description string + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdateCategory(ctx context.Context, arg UpdateCategoryParams) error { + _, err := q.db.ExecContext(ctx, updateCategory, + arg.Name, + arg.Slug, + arg.Description, + arg.UpdatedAt, + arg.ID, + ) + return err +} diff --git a/providers/db/gen/sqlgen/db.go b/providers/db/gen/sqlgen/db.go index 7d9d9e7..8eab959 100644 --- a/providers/db/gen/sqlgen/db.go +++ b/providers/db/gen/sqlgen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 package sqlgen diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 4f69bd0..788c292 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -1,9 +1,20 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 package sqlgen +type Category struct { + ID int64 + SiteID int64 + Guid string + Name string + Slug string + Description string + CreatedAt int64 + UpdatedAt int64 +} + type PendingUpload struct { ID int64 SiteID int64 @@ -29,6 +40,11 @@ type Post struct { DeletedAt int64 } +type PostCategory struct { + PostID int64 + CategoryID int64 +} + type PublishTarget struct { ID int64 SiteID int64 diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index a831bbe..63eeb60 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: pending_uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index d512941..8bff191 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: posts.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go index cd5cfa6..69c09df 100644 --- a/providers/db/gen/sqlgen/pubtargets.sql.go +++ b/providers/db/gen/sqlgen/pubtargets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: pubtargets.sql package sqlgen diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index 1a1b965..bd80fb3 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: sites.sql package sqlgen diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 0433ae9..189de2d 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index a70a3bf..6007589 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.30.0 +// sqlc v1.28.0 // source: users.sql package sqlgen diff --git a/sql/queries/categories.sql b/sql/queries/categories.sql new file mode 100644 index 0000000..4b48506 --- /dev/null +++ b/sql/queries/categories.sql @@ -0,0 +1,53 @@ +-- 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 = ?; From 15bc6b7f73e144114fbceaba40c8134ba393efd1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:37:01 +1100 Subject: [PATCH 14/62] feat: add DB provider methods for categories Implements SaveCategory, SelectCategory, SelectCategoriesOfSite, SelectCategoryBySlugAndSite, DeleteCategory, SelectCategoriesOfPost, SelectPostsOfCategory, CountPostsOfCategory, and SetPostCategories on the DB Provider, along with BeginTx/QueriesWithTx for transaction support. Also fixes pre-existing compilation errors in provider_test.go (missing PagingParams args) so new tests can compile and run. Co-Authored-By: Claude Opus 4.6 --- providers/db/categories.go | 132 +++++++++++++++++++++++++++ providers/db/provider.go | 11 +++ providers/db/provider_test.go | 165 +++++++++++++++++++++++++++++++++- 3 files changed, 305 insertions(+), 3 deletions(-) create mode 100644 providers/db/categories.go diff --git a/providers/db/categories.go b/providers/db/categories.go new file mode 100644 index 0000000..72fac94 --- /dev/null +++ b/providers/db/categories.go @@ -0,0 +1,132 @@ +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. +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(), + } +} diff --git a/providers/db/provider.go b/providers/db/provider.go index eda0513..cc35225 100644 --- a/providers/db/provider.go +++ b/providers/db/provider.go @@ -40,6 +40,17 @@ func (db *Provider) Close() error { return db.drvr.Close() } +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), + } +} + func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error { return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{ DeletedAt: time.Now().Unix(), diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index 4781d61..caf83d1 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -158,7 +158,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.NotZero(t, post.ID) - posts, err := p.SelectPostsOfSite(ctx, site.ID, false) + posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{}) require.NoError(t, err) require.Len(t, posts, 1) assert.Equal(t, post.ID, posts[0].ID) @@ -205,7 +205,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SavePost(ctx, post1)) require.NoError(t, p.SavePost(ctx, post2)) - posts, err := p.SelectPostsOfSite(ctx, site2.ID, false) + posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{}) require.NoError(t, err) require.Len(t, posts, 2) assert.Equal(t, "New Post", posts[0].Title) @@ -220,7 +220,7 @@ func TestProvider_Posts(t *testing.T) { } require.NoError(t, p.SaveSite(ctx, emptySite)) - posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false) + posts, err := p.SelectPostsOfSite(ctx, emptySite.ID, false, db.PagingParams{}) require.NoError(t, err) assert.Empty(t, posts) }) @@ -283,6 +283,165 @@ func TestProvider_PublishTargets(t *testing.T) { }) } +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) { + 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) + }) +} + // Verify that password encoding roundtrips correctly through base64 func TestProvider_UserPasswordEncoding(t *testing.T) { ctx := context.Background() From 3c80f63a55168ddc152c9a695722c6c5f1f7b361 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:38:41 +1100 Subject: [PATCH 15/62] feat: add categories service with CRUD and slug validation --- services/categories/service.go | 162 +++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 services/categories/service.go diff --git a/services/categories/service.go b/services/categories/service.go new file mode 100644 index 0000000..57b509d --- /dev/null +++ b/services/categories/service.go @@ -0,0 +1,162 @@ +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: models.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 +} From ffa86b12e94175733c39ab19560876f42ba9c381 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:42:17 +1100 Subject: [PATCH 16/62] 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 --- cmds/server.go | 8 +++ handlers/categories.go | 101 ++++++++++++++++++++++++++++++++++++ services/services.go | 4 ++ views/_common/nav.html | 3 ++ views/categories/edit.html | 47 +++++++++++++++++ views/categories/index.html | 35 +++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 handlers/categories.go create mode 100644 views/categories/edit.html create mode 100644 views/categories/index.html diff --git a/cmds/server.go b/cmds/server.go index 40c2690..6b2e71b 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -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) diff --git a/handlers/categories.go b/handlers/categories.go new file mode 100644 index 0000000..ec5e9ca --- /dev/null +++ b/handlers/categories.go @@ -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)) +} diff --git a/services/services.go b/services/services.go index 606e932..beb6727 100644 --- a/services/services.go +++ b/services/services.go @@ -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 } diff --git a/views/_common/nav.html b/views/_common/nav.html index 87801d2..e8bce30 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -10,6 +10,9 @@ + diff --git a/views/categories/edit.html b/views/categories/edit.html new file mode 100644 index 0000000..c838778 --- /dev/null +++ b/views/categories/edit.html @@ -0,0 +1,47 @@ +
+
+

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
+ + {{ if .isNew }} +
+ {{ else }} + + {{ end }} + +
+ +
+ +
+
+
+ +
+ +
Auto-generated from name if left blank.
+
+
+
+ +
+ +
Markdown supported. Displayed on the category archive page.
+
+
+
+
+
+ + {{ if not .isNew }} + + {{ end }} +
+
+
+ + {{ if not .isNew }} + + {{ end }} +
diff --git a/views/categories/index.html b/views/categories/index.html new file mode 100644 index 0000000..f768977 --- /dev/null +++ b/views/categories/index.html @@ -0,0 +1,35 @@ +
+
+

Categories

+ +
+ + + + + + + + + + + + {{ range .categories }} + + + + + + + {{ else }} + + + + {{ end }} + +
NameSlugPosts
{{ .Name }}{{ .Slug }}{{ .PostCount }} + Edit +
No categories yet.
+
From 4c2ce7272d3e9a06e4f2ba5ab06490fb0f3fd4c5 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:45:28 +1100 Subject: [PATCH 17/62] feat: add category selection to post edit form and badges to post list Co-Authored-By: Claude Sonnet 4.6 --- cmds/server.go | 2 +- handlers/posts.go | 32 ++++++++++++++++++++++--- services/posts/create.go | 25 ++++++++++++++++---- services/posts/list.go | 21 +++++++++++++++-- views/posts/edit.html | 51 ++++++++++++++++++++++++++++------------ views/posts/index.html | 11 +++++---- 6 files changed, 112 insertions(+), 30 deletions(-) diff --git a/cmds/server.go b/cmds/server.go index 6b2e71b..56517e7 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -109,7 +109,7 @@ Starting weiro without any arguments will start the server. ih := handlers.IndexHandler{SiteService: svcs.Sites} lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} - ph := handlers.PostsHandler{PostService: svcs.Posts} + ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} diff --git a/handlers/posts.go b/handlers/posts.go index 3f282e0..e0234fc 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -6,11 +6,13 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/posts" ) type PostsHandler struct { - PostService *posts.Service + PostService *posts.Service + CategoryService *categories.Service } func (ph PostsHandler) Index(c fiber.Ctx) error { @@ -42,8 +44,15 @@ func (ph PostsHandler) New(c fiber.Ctx) error { State: models.StateDraft, } + cats, err := ph.CategoryService.ListCategories(c.Context()) + if err != nil { + return err + } + return c.Render("posts/edit", fiber.Map{ - "post": p, + "post": p, + "categories": cats, + "selectedCategories": map[int64]bool{}, }) } @@ -62,11 +71,28 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { 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, + "post": post, + "categories": cats, + "selectedCategories": selectedCategories, }) })) } diff --git a/services/posts/create.go b/services/posts/create.go index f73d49c..b1a6466 100644 --- a/services/posts/create.go +++ b/services/posts/create.go @@ -10,10 +10,11 @@ import ( ) type CreatePostParams struct { - GUID string `form:"guid" json:"guid"` - Title string `form:"title" json:"title"` - Body string `form:"body" json:"body"` - Action string `form:"action" json:"action"` + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Body string `form:"body" json:"body"` + Action string `form:"action" json:"action"` + CategoryIDs []int64 `form:"category_ids" json:"category_ids"` } func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) { @@ -53,7 +54,21 @@ func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*mod // Leave unchanged } - if err := s.db.SavePost(ctx, post); err != nil { + // 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 } diff --git a/services/posts/list.go b/services/posts/list.go index ae70e1c..15e14d3 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -7,7 +7,12 @@ import ( "lmika.dev/lmika/weiro/providers/db" ) -func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Post, error) { +type PostWithCategories struct { + *models.Post + Categories []*models.Category +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { site, ok := models.GetSite(ctx) if !ok { return nil, models.SiteRequiredError @@ -21,7 +26,15 @@ func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*models.Po return nil, err } - return posts, nil + 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 } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { @@ -32,3 +45,7 @@ func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) return post, nil } + +func (s *Service) GetPostCategories(ctx context.Context, postID int64) ([]*models.Category, error) { + return s.db.SelectCategoriesOfPost(ctx, postID) +} diff --git a/views/posts/edit.html b/views/posts/edit.html index 475c9a0..07be770 100644 --- a/views/posts/edit.html +++ b/views/posts/edit.html @@ -4,20 +4,41 @@ 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 }}"> - -
- -
-
- -
-
- {{ if $isPublished }} - - {{ else }} - - - {{ end }} +
+
+ +
+ +
+
+ +
+
+ {{ if $isPublished }} + + {{ else }} + + + {{ end }} +
+
+
+
+
Categories
+
+ {{ range .categories }} +
+ + +
+ {{ else }} + No categories yet. + {{ end }} +
+
+
- \ No newline at end of file + diff --git a/views/posts/index.html b/views/posts/index.html index 6470c24..3d2597f 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -26,11 +26,14 @@ {{ if $p.Title }}

{{ $p.Title }}

{{ end }} {{ markdown $p.Body $.site }} -
- {{ if eq .State 1 }} - {{ $.user.FormatTime .UpdatedAt }} Draft +
+ {{ if eq $p.State 1 }} + {{ $.user.FormatTime $p.UpdatedAt }} Draft {{ else }} - {{ $.user.FormatTime .PublishedAt }} + {{ $.user.FormatTime $p.PublishedAt }} + {{ end }} + {{ range $p.Categories }} + {{ .Name }} {{ end }}
From 6c69131b032492ca254ead9b4b1fb8b73fbc1126 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 21:51:19 +1100 Subject: [PATCH 18/62] feat: add category pages and per-category feeds to site builder Extend the publishing pipeline to generate category index pages, per-category archive pages, per-category RSS/JSON feeds, and display categories on individual post pages and post lists. Co-Authored-By: Claude Opus 4.6 --- layouts/simplecss/categories_list.html | 9 + layouts/simplecss/categories_single.html | 16 ++ layouts/simplecss/posts_list.html | 7 + layouts/simplecss/posts_single.html | 9 +- models/pubmodel/sites.go | 10 +- providers/sitebuilder/builder.go | 209 +++++++++++++++++++---- providers/sitebuilder/builder_test.go | 43 +++-- providers/sitebuilder/tmpls.go | 33 +++- services/publisher/iter.go | 25 ++- services/publisher/service.go | 29 +++- 10 files changed, 329 insertions(+), 61 deletions(-) create mode 100644 layouts/simplecss/categories_list.html create mode 100644 layouts/simplecss/categories_single.html diff --git a/layouts/simplecss/categories_list.html b/layouts/simplecss/categories_list.html new file mode 100644 index 0000000..32331f6 --- /dev/null +++ b/layouts/simplecss/categories_list.html @@ -0,0 +1,9 @@ +

Categories

+
    +{{ range .Categories }} +
  • + {{ .Name }} ({{ .PostCount }}) + {{ if .DescriptionBrief }}
    {{ .DescriptionBrief }}{{ end }} +
  • +{{ end }} +
\ No newline at end of file diff --git a/layouts/simplecss/categories_single.html b/layouts/simplecss/categories_single.html new file mode 100644 index 0000000..e8d59d1 --- /dev/null +++ b/layouts/simplecss/categories_single.html @@ -0,0 +1,16 @@ +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} + {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} +{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html index 944c5a1..e6a77fe 100644 --- a/layouts/simplecss/posts_list.html +++ b/layouts/simplecss/posts_list.html @@ -2,4 +2,11 @@ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} {{ .HTML }} {{ format_date .Post.PublishedAt }} + {{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+ {{ end }} {{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_single.html b/layouts/simplecss/posts_single.html index 5fd9fcb..cda9bb2 100644 --- a/layouts/simplecss/posts_single.html +++ b/layouts/simplecss/posts_single.html @@ -1,3 +1,10 @@ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} {{ .HTML }} -{{ format_date .Post.PublishedAt }} \ No newline at end of file +{{ format_date .Post.PublishedAt }} +{{ if .Categories }} +

+ {{ range .Categories }} + {{ .Name }} + {{ end }} +

+{{ end }} \ No newline at end of file diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index a745885..a8862c4 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -11,11 +11,11 @@ import ( type Site struct { models.Site BaseURL string - //Posts []*models.Post Uploads []models.Upload - OpenUpload func(u models.Upload) (io.ReadCloser, error) - - // PostItr returns a new post iterator - PostIter func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] + 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) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 4908d61..5775149 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -31,7 +31,7 @@ type Builder struct { func New(site pubmodel.Site, opts Options) (*Builder, error) { tmpls, err := template.New(""). Funcs(templateFns(site, opts)). - ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain) + ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) if err != nil { return nil, err } @@ -62,7 +62,13 @@ func (b *Builder) BuildSite(outDir string) error { if err != nil { return err } - if err := b.writePost(buildCtx, post); err != nil { + 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 } } @@ -70,10 +76,7 @@ func (b *Builder) BuildSite(outDir string) error { }) eg.Go(func() error { - if err := b.renderPostList(buildCtx, b.site.PostIter(ctx)); err != nil { - return err - } - return nil + return b.renderPostListWithCategories(buildCtx, ctx) }) eg.Go(func() error { @@ -93,43 +96,42 @@ func (b *Builder) BuildSite(outDir string) error { return nil }) - // Copy uploads + // Category pages eg.Go(func() error { - if err := b.writeUploads(buildCtx, b.site.Uploads); err != nil { + if err := b.renderCategoryList(buildCtx); err != nil { return err } - return nil + return b.renderCategoryPages(buildCtx, ctx) }) - if err := eg.Wait(); err != nil { - return err - } - return nil + // Copy uploads + eg.Go(func() error { + return b.writeUploads(buildCtx, b.site.Uploads) + }) + + return eg.Wait() } -func (b *Builder) renderPostList(ctx buildContext, postIter iter.Seq[models.Maybe[*models.Post]]) error { - // TODO: paging - postCopy := make([]*models.Post, 0) - for mp := range postIter { +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 } - postCopy = append(postCopy, post) + rp, err := b.renderPostWithCategories(ctx, post) + if err != nil { + return err + } + posts = append(posts, rp) } pl := postListData{ commonData: commonData{Site: b.site}, - } - for _, post := range postCopy { - rp, err := b.renderPost(post) - if err != nil { - return err - } - pl.Posts = append(pl.Posts, rp) + Posts: posts, } - return b.createAtPath(ctx, "", func(f io.Writer) error { + return b.createAtPath(bctx, "", func(f io.Writer) error { return b.renderTemplate(f, tmplNamePostList, pl) }) } @@ -156,16 +158,29 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* return err } + 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, ", ") + } + } + postTitle := post.Title if postTitle != "" { postTitle = opts.titlePrefix + postTitle } feed.Items = append(feed.Items, &feedhub.Item{ - Id: filepath.Join(b.site.BaseURL, post.GUID), - Title: postTitle, - Link: &feedhub.Link{Href: renderedPost.PostURL}, - Content: string(renderedPost.HTML), + Id: filepath.Join(b.site.BaseURL, post.GUID), + Title: postTitle, + Link: &feedhub.Link{Href: renderedPost.PostURL}, + Content: string(renderedPost.HTML), + Category: catName, // TO FIX: Created should be first published Created: post.PublishedAt, Updated: post.UpdatedAt, @@ -243,14 +258,142 @@ func (b *Builder) renderPost(post *models.Post) (postSingleData, error) { }, nil } -func (b *Builder) writePost(ctx buildContext, post *models.Post) error { +// 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 +} + +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, rp.Path, func(f io.Writer) error { - return b.renderTemplate(f, tmplNamePostSingle, rp) + 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 }) } diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index 2564f6d..cbe116b 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -1,6 +1,8 @@ package sitebuilder_test import ( + "context" + "iter" "os" "path/filepath" "testing" @@ -15,24 +17,36 @@ import ( 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}}{{.Post.Title}},{{ end }}`)}, - "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "posts_single.html": {Data: []byte(`{{ .HTML }}`)}, + "posts_list.html": {Data: []byte(`{{ range .Posts}}{{.Post.Title}},{{ end }}`)}, + "layout_main.html": {Data: []byte(`{{ .Body }}`)}, + "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, + "categories_single.html": {Data: []byte(`

{{.Category.Name}}

`)}, + } + + 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", - 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", - }, + 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{ @@ -58,5 +72,4 @@ func TestBuilder_BuildSite(t *testing.T) { assert.Equal(t, content, string(fileContent)) } }) - } diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index fa70b6d..2152290 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -20,6 +20,12 @@ const ( // tmplNameLayoutMain is the template for the main layout (layoutMainData) tmplNameLayoutMain = "layout_main.html" + + // tmplNameCategoryList is the template for the category index page + tmplNameCategoryList = "categories_list.html" + + // tmplNameCategorySingle is the template for a single category page + tmplNameCategorySingle = "categories_single.html" ) type Options struct { @@ -41,10 +47,11 @@ type commonData struct { type postSingleData struct { commonData - Post *models.Post - HTML template.HTML - Path string - PostURL string + Post *models.Post + HTML template.HTML + Path string + PostURL string + Categories []*models.Category } type postListData struct { @@ -56,3 +63,21 @@ type layoutData struct { commonData Body template.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 +} diff --git a/services/publisher/iter.go b/services/publisher/iter.go index 48b5252..ea70616 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -8,7 +8,7 @@ import ( "lmika.dev/lmika/weiro/providers/db" ) -// PostIter returns a post iterator which returns posts in reverse chronological order. +// postIter returns a post iterator which returns posts in reverse chronological order. func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} @@ -39,3 +39,26 @@ func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Ma } } } + +// postIterByCategory returns a post iterator for posts in a specific category. +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 + } + } +} diff --git a/services/publisher/service.go b/services/publisher/service.go index f0b39f0..2ed9046 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -46,6 +46,24 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { return err } + // 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: models.BriefDescription(cat.Description), + }) + } + for _, target := range targets { if !target.Enabled { continue @@ -56,8 +74,15 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { return p.postIter(ctx, site.ID) }, - BaseURL: target.BaseURL, - Uploads: uploads, + 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) }, From 9efa40879f6e6e972cde3e4ccfd714b6d60293b5 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 22:11:33 +1100 Subject: [PATCH 19/62] fix: improve error handling in categories service - Slug collision checks now properly propagate real DB errors instead of silently ignoring them - GetCategory now verifies site ownership, matching the pattern used by UpdateCategory and DeleteCategory Co-Authored-By: Claude Opus 4.6 --- services/categories/service.go | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/services/categories/service.go b/services/categories/service.go index 57b509d..c45280e 100644 --- a/services/categories/service.go +++ b/services/categories/service.go @@ -61,7 +61,19 @@ func (s *Service) ListCategoriesWithCounts(ctx context.Context) ([]models.Catego } func (s *Service) GetCategory(ctx context.Context, id int64) (*models.Category, error) { - return s.db.SelectCategory(ctx, id) + 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 + } + return cat, nil } func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParams) (*models.Category, error) { @@ -79,6 +91,8 @@ func (s *Service) CreateCategory(ctx context.Context, params CreateCategoryParam // Check for slug collision if _, err := s.db.SelectCategoryBySlugAndSite(ctx, site.ID, slug); err == nil { return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err } cat := &models.Category{ @@ -124,6 +138,8 @@ func (s *Service) UpdateCategory(ctx context.Context, id int64, params CreateCat // 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 + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err } cat.Name = params.Name From 740cf8979ac025aa94f0daf7ee8ddf7184e405f2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 18 Mar 2026 22:19:26 +1100 Subject: [PATCH 20/62] fix: unit tests --- handlers/posts.go | 3 +- models/ids_test.go | 4 +- providers/db/provider_test.go | 19 +++-- providers/sitereader/provider.go | 94 ----------------------- providers/sitereader/provider_test.go | 106 -------------------------- services/import/service.go | 54 ------------- 6 files changed, 16 insertions(+), 264 deletions(-) delete mode 100644 providers/sitereader/provider.go delete mode 100644 providers/sitereader/provider_test.go delete mode 100644 services/import/service.go diff --git a/handlers/posts.go b/handlers/posts.go index e0234fc..a339685 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -145,8 +145,7 @@ func (ph PostsHandler) Patch(c fiber.Ctx) error { return accepts(c, json(func() any { return struct{}{} }), html(func(c fiber.Ctx) error { - - return c.Redirect().To(fmt.Sprintf("/sites/%v/posts")) + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", models.MustGetSite(c.Context()).ID)) })) } diff --git a/models/ids_test.go b/models/ids_test.go index e57daf0..8d933fc 100644 --- a/models/ids_test.go +++ b/models/ids_test.go @@ -7,8 +7,8 @@ import ( func TestNewNanoID(t *testing.T) { id := NewNanoID() - if len(id) != 12 { - t.Errorf("Expected ID length of 12, got %d", len(id)) + if len(id) != 16 { + t.Errorf("Expected ID length of 16, got %d", len(id)) } if id == "" { diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index caf83d1..06f03c0 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -98,6 +98,7 @@ func TestProvider_Sites(t *testing.T) { t.Run("select site by id", func(t *testing.T) { site := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "Lookup Blog", Tagline: "Find me by ID", } @@ -143,10 +144,11 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SaveSite(ctx, site)) t.Run("save and select posts", func(t *testing.T) { + guid := models.NewNanoID() now := time.Date(2026, 2, 19, 12, 0, 0, 0, time.UTC) post := &models.Post{ SiteID: site.ID, - GUID: "post-001", + GUID: guid, Title: "First Post", Body: "Hello world", Slug: "/2026/02/19/first-post", @@ -158,12 +160,12 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.NotZero(t, post.ID) - posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, site.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.Len(t, posts, 1) assert.Equal(t, post.ID, posts[0].ID) assert.Equal(t, site.ID, posts[0].SiteID) - assert.Equal(t, "post-001", posts[0].GUID) + assert.Equal(t, guid, posts[0].GUID) assert.Equal(t, "First Post", posts[0].Title) assert.Equal(t, "Hello world", posts[0].Body) assert.Equal(t, "/2026/02/19/first-post", posts[0].Slug) @@ -173,8 +175,10 @@ func TestProvider_Posts(t *testing.T) { t.Run("posts ordered by created_at desc", func(t *testing.T) { // Create a second site to isolate this test + guid := models.NewNanoID() site2 := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "Second Blog", Tagline: "", } @@ -185,7 +189,7 @@ func TestProvider_Posts(t *testing.T) { post1 := &models.Post{ SiteID: site2.ID, - GUID: "old-post", + GUID: guid, Title: "Old Post", Body: "old", Slug: "/old", @@ -194,7 +198,7 @@ func TestProvider_Posts(t *testing.T) { } post2 := &models.Post{ SiteID: site2.ID, - GUID: "new-post", + GUID: models.NewNanoID(), Title: "New Post", Body: "new", Slug: "/new", @@ -205,7 +209,7 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, p.SavePost(ctx, post1)) require.NoError(t, p.SavePost(ctx, post2)) - posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{}) + posts, err := p.SelectPostsOfSite(ctx, site2.ID, false, db.PagingParams{Limit: 10, Offset: 0}) require.NoError(t, err) require.Len(t, posts, 2) assert.Equal(t, "New Post", posts[0].Title) @@ -215,6 +219,7 @@ func TestProvider_Posts(t *testing.T) { t.Run("select posts for site with no posts", func(t *testing.T) { emptySite := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "Empty Blog", Tagline: "", } @@ -239,6 +244,7 @@ func TestProvider_PublishTargets(t *testing.T) { site := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "My Blog", Tagline: "A test blog", } @@ -272,6 +278,7 @@ func TestProvider_PublishTargets(t *testing.T) { t.Run("select targets for site with no targets", func(t *testing.T) { emptySite := &models.Site{ OwnerID: user.ID, + GUID: models.NewNanoID(), Title: "No Targets", Tagline: "", } diff --git a/providers/sitereader/provider.go b/providers/sitereader/provider.go deleted file mode 100644 index 1365d4b..0000000 --- a/providers/sitereader/provider.go +++ /dev/null @@ -1,94 +0,0 @@ -package sitereader - -import ( - "bytes" - "io" - "io/fs" - "time" - - "gopkg.in/yaml.v3" - "lmika.dev/lmika/weiro/models" -) - -type Provider struct { - fs fs.FS -} - -func New(fs fs.FS) *Provider { - return &Provider{ - fs: fs, - } -} - -func (p *Provider) ReadSite() (ReadSiteModels, error) { - posts, err := p.ListPosts() - if err != nil { - return ReadSiteModels{}, err - } - - meta := siteMeta{} - metaBytes, err := fs.ReadFile(p.fs, "site.yaml") - if err != nil { - return ReadSiteModels{}, err - } - if err := yaml.Unmarshal(metaBytes, &meta); err != nil { - return ReadSiteModels{}, err - } - - site := models.Site{ - Title: meta.Title, - Tagline: meta.Tagline, - } - - return ReadSiteModels{ - Site: site, - Posts: posts, - }, nil -} - -func (p *Provider) ListPosts() (posts []*models.Post, err error) { - err = fs.WalkDir(p.fs, "posts", func(path string, d fs.DirEntry, err error) error { - if err != nil { - return err - } else if d.IsDir() { - return nil - } - - post, err := p.ReadPost(path) - if err != nil { - return err - } - posts = append(posts, post) - return nil - }) - return posts, err -} - -func (p *Provider) ReadPost(path string) (*models.Post, error) { - data, err := fs.ReadFile(p.fs, path) - if err != nil { - return nil, err - } - - // Split front matter and content - parts := bytes.SplitN(data, []byte("---"), 3) - if len(parts) < 3 { - return nil, io.ErrUnexpectedEOF - } - - var meta postMeta - if err := yaml.Unmarshal(parts[1], &meta); err != nil { - return nil, err - } - - post := models.Post{ - Slug: meta.Slug, - Title: meta.Title, - GUID: meta.ID, - PublishedAt: meta.Date, - CreatedAt: time.Now(), - } - - post.Body = string(bytes.TrimPrefix(parts[2], []byte("\n"))) - return &post, nil -} diff --git a/providers/sitereader/provider_test.go b/providers/sitereader/provider_test.go deleted file mode 100644 index 0b012eb..0000000 --- a/providers/sitereader/provider_test.go +++ /dev/null @@ -1,106 +0,0 @@ -package sitereader_test - -import ( - "testing" - "testing/fstest" - "time" - - "github.com/stretchr/testify/assert" - "lmika.dev/lmika/weiro/providers/sitereader" -) - -func TestProvider_ReadPost(t *testing.T) { - t.Run("with meta", func(t *testing.T) { - testFS := fstest.MapFS{ - "site.yaml": {Data: []byte(`base_url: https://example.com`)}, - "posts/test.md": {Data: []byte(`--- -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - post, err := pr.ReadPost("posts/test.md") - assert.NoError(t, err) - assert.Equal(t, "Test Post Here", post.Title) - assert.Equal(t, time.Date(2026, 2, 18, 19, 59, 0, 0, time.UTC), post.PublishedAt) - assert.Equal(t, "This is just a test post.\n", post.Body) - }) - - t.Run("without meta", func(t *testing.T) { - testFS := fstest.MapFS{ - "posts/test.md": {Data: []byte(`--- ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - post, err := pr.ReadPost("posts/test.md") - assert.NoError(t, err) - assert.Equal(t, "", post.Title) - assert.Equal(t, "This is just a test post.\n", post.Body) - }) -} - -func TestProvider_ListPosts(t *testing.T) { - testFS := fstest.MapFS{ - "posts/01-post1.md": {Data: []byte(`--- -id: 111 -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - "posts/02-post2.md": {Data: []byte(`--- -id: 222 ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - posts, err := pr.ListPosts() - assert.NoError(t, err) - - assert.Equal(t, 2, len(posts)) - - assert.Equal(t, "111", posts[0].GUID) - assert.Equal(t, "222", posts[1].GUID) -} - -func TestProvider_ReadSite(t *testing.T) { - testFS := fstest.MapFS{ - "site.yaml": {Data: []byte(`base_url: https://example.com`)}, - "posts/01-post1.md": {Data: []byte(`--- -id: 111 -date: 2026-02-18T19:59:00Z -title: Test Post Here -tags: [test, example] ---- -This is just a test post. -`)}, - "posts/02-post2.md": {Data: []byte(`--- -id: 222 ---- -This is just a test post. -`)}, - } - - pr := sitereader.New(testFS) - - sites, err := pr.ReadSite() - assert.NoError(t, err) - - assert.Equal(t, 2, len(sites.Posts)) - - assert.Equal(t, "111", sites.Posts[0].GUID) - assert.Equal(t, "222", sites.Posts[1].GUID) -} diff --git a/services/import/service.go b/services/import/service.go deleted file mode 100644 index e4aee94..0000000 --- a/services/import/service.go +++ /dev/null @@ -1,54 +0,0 @@ -package _import - -import ( - "context" - "os" - - "emperror.dev/errors" - "lmika.dev/lmika/weiro/models" - "lmika.dev/lmika/weiro/providers/db" - "lmika.dev/lmika/weiro/providers/sitereader" -) - -type Service struct { - db *db.Provider -} - -func New(db *db.Provider) *Service { - return &Service{ - db: db, - } -} - -func (s *Service) Import(ctx context.Context, sitePath string) (models.Site, error) { - user, ok := models.GetUser(ctx) - if !ok { - return models.Site{}, models.UserRequiredError - } - - sr := sitereader.New(os.DirFS(sitePath)) - - readSite, err := sr.ReadSite() - if err != nil { - return models.Site{}, errors.Wrap(err, "failed to read site") - } - - site := readSite.Site - site.OwnerID = user.ID - - if err := s.db.SaveSite(ctx, &site); err != nil { - return models.Site{}, errors.Wrap(err, "failed to save site") - } - - for _, post := range readSite.Posts { - post.SiteID = site.ID - if post.GUID == "" { - post.GUID = models.NewNanoID() - } - if err := s.db.SavePost(ctx, post); err != nil { - return models.Site{}, errors.Wrap(err, "failed to save post") - } - } - - return site, nil -} From f45bdcd83c31140392cf89cc76412df4f294ddbf Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 21 Mar 2026 12:01:24 +1100 Subject: [PATCH 21/62] Styled the admin section of categories. --- assets/css/main.scss | 59 ++++++++++++++++++++++++-------- handlers/posts.go | 2 ++ providers/sitebuilder/builder.go | 9 ++--- views/categories/edit.html | 10 +++--- views/categories/index.html | 39 +++++++++------------ views/layouts/main.html | 2 +- views/posts/edit.html | 6 ++-- views/posts/index.html | 7 ++-- 8 files changed, 78 insertions(+), 56 deletions(-) diff --git a/assets/css/main.scss b/assets/css/main.scss index c8f0344..dc6ad7d 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -10,21 +10,7 @@ $container-max-widths: ( @import "bootstrap/scss/bootstrap.scss"; -// Local classes - -.post-form { - display: grid; - grid-template-rows: min-content auto min-content; - position: absolute; - top: 0; - bottom: 0; - left: 0; - right: 0; -} - -.post-form textarea { - height: 100%; -} +// Post list .postlist .post img { max-width: 300px; @@ -32,6 +18,49 @@ $container-max-widths: ( max-height: 300px; } +.postlist .post-date { + font-size: 0.9rem; +} + +// Post form + +// Post edit page styling +.post-edit-page { + height: 100vh; +} + +.post-edit-page main { + display: flex; + flex-direction: column; + overflow: hidden; +} + +.post-edit-page .post-form { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +.post-edit-page .post-form .row { + flex: 1; + display: flex; + min-height: 0; +} + +.post-edit-page .post-form .col-md-9 { + display: flex; + flex-direction: column; +} + +.post-edit-page .post-form textarea { + flex: 1; + resize: vertical; + min-height: 300px; +} + + + .show-upload figure img { max-width: 100vw; height: auto; diff --git a/handlers/posts.go b/handlers/posts.go index a339685..a133758 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -53,6 +53,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error { "post": p, "categories": cats, "selectedCategories": map[int64]bool{}, + "bodyClass": "post-edit-page", }) } @@ -93,6 +94,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { "post": post, "categories": cats, "selectedCategories": selectedCategories, + "bodyClass": "post-edit-page", }) })) } diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 5775149..346f77c 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -176,10 +176,11 @@ func (b *Builder) renderFeeds(ctx buildContext, postIter iter.Seq[models.Maybe[* } feed.Items = append(feed.Items, &feedhub.Item{ - Id: filepath.Join(b.site.BaseURL, post.GUID), - Title: postTitle, - Link: &feedhub.Link{Href: renderedPost.PostURL}, - Content: string(renderedPost.HTML), + Id: filepath.Join(b.site.BaseURL, post.GUID), + Title: postTitle, + Link: &feedhub.Link{Href: renderedPost.PostURL}, + Content: string(renderedPost.HTML), + // TO FIX: Why the heck does this only include the first category? Category: catName, // TO FIX: Created should be first published Created: post.PublishedAt, diff --git a/views/categories/edit.html b/views/categories/edit.html index c838778..c6c3606 100644 --- a/views/categories/edit.html +++ b/views/categories/edit.html @@ -1,6 +1,6 @@
-

{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}

+
{{ if .isNew }}New Category{{ else }}Edit Category{{ end }}
{{ if .isNew }} @@ -10,27 +10,27 @@ {{ end }}
- +
- +
Auto-generated from name if left blank.
- +
Markdown supported. Displayed on the category archive page.
-
+
{{ if not .isNew }} diff --git a/views/categories/index.html b/views/categories/index.html index f768977..026d919 100644 --- a/views/categories/index.html +++ b/views/categories/index.html @@ -1,35 +1,30 @@
-

Categories

- - - - - - - - - - - {{ range .categories }} + {{ range .categories }} +
NameSlugPosts
+ + + + + + + + - - {{ else }} - - - - {{ end }} - -
NameSlugPosts
{{ .Name }} {{ .Slug }} {{ .PostCount }} - Edit -
No categories yet.
+ + + {{ else }} +
+
📚
No categories yet.
+
+ {{ end }}
diff --git a/views/layouts/main.html b/views/layouts/main.html index 2b81177..908094f 100644 --- a/views/layouts/main.html +++ b/views/layouts/main.html @@ -7,7 +7,7 @@ - + {{ template "_common/nav" . }} {{ embed }} diff --git a/views/posts/edit.html b/views/posts/edit.html index 07be770..d162788 100644 --- a/views/posts/edit.html +++ b/views/posts/edit.html @@ -1,6 +1,6 @@ {{ $isPublished := ne .post.State 1 }}
-
@@ -10,9 +10,7 @@
-
- -
+
{{ if $isPublished }} diff --git a/views/posts/index.html b/views/posts/index.html index 3d2597f..bbf445d 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -28,12 +28,9 @@
{{ if eq $p.State 1 }} - {{ $.user.FormatTime $p.UpdatedAt }} Draft + Draft {{ else }} - {{ $.user.FormatTime $p.PublishedAt }} - {{ end }} - {{ range $p.Categories }} - {{ .Name }} + {{ end }}
From d9aec4af2c98abf0fc0f96f50b4b5f24f0ed2fb2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 10:28:33 +1100 Subject: [PATCH 22/62] Styled the categories on the site --- layouts/simplecss/categories_list.html | 9 --- layouts/simplecss/categories_single.html | 16 ------ layouts/simplecss/fs.go | 3 +- layouts/simplecss/posts_list.html | 12 ---- layouts/simplecss/posts_single.html | 10 ---- layouts/simplecss/static/style.css | 55 +++++++++++++++++++ layouts/simplecss/templates/_post_meta.html | 10 ++++ .../simplecss/templates/categories_list.html | 9 +++ .../templates/categories_single.html | 11 ++++ .../{ => templates}/layout_main.html | 1 + layouts/simplecss/templates/posts_list.html | 8 +++ layouts/simplecss/templates/posts_single.html | 5 ++ providers/sitebuilder/builder.go | 47 +++++++++++++++- providers/sitebuilder/tmpls.go | 9 ++- services/publisher/service.go | 16 +++++- views/categories/index.html | 14 +++-- 16 files changed, 176 insertions(+), 59 deletions(-) delete mode 100644 layouts/simplecss/categories_list.html delete mode 100644 layouts/simplecss/categories_single.html delete mode 100644 layouts/simplecss/posts_list.html delete mode 100644 layouts/simplecss/posts_single.html create mode 100644 layouts/simplecss/static/style.css create mode 100644 layouts/simplecss/templates/_post_meta.html create mode 100644 layouts/simplecss/templates/categories_list.html create mode 100644 layouts/simplecss/templates/categories_single.html rename layouts/simplecss/{ => templates}/layout_main.html (89%) create mode 100644 layouts/simplecss/templates/posts_list.html create mode 100644 layouts/simplecss/templates/posts_single.html diff --git a/layouts/simplecss/categories_list.html b/layouts/simplecss/categories_list.html deleted file mode 100644 index 32331f6..0000000 --- a/layouts/simplecss/categories_list.html +++ /dev/null @@ -1,9 +0,0 @@ -

Categories

-
    -{{ range .Categories }} -
  • - {{ .Name }} ({{ .PostCount }}) - {{ if .DescriptionBrief }}
    {{ .DescriptionBrief }}{{ end }} -
  • -{{ end }} -
\ No newline at end of file diff --git a/layouts/simplecss/categories_single.html b/layouts/simplecss/categories_single.html deleted file mode 100644 index e8d59d1..0000000 --- a/layouts/simplecss/categories_single.html +++ /dev/null @@ -1,16 +0,0 @@ -

{{ .Category.Name }}

-{{ if .DescriptionHTML }} -
{{ .DescriptionHTML }}
-{{ end }} -{{ range .Posts }} - {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} - {{ .HTML }} - {{ format_date .Post.PublishedAt }} - {{ if .Categories }} -

- {{ range .Categories }} - {{ .Name }} - {{ end }} -

- {{ end }} -{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/fs.go b/layouts/simplecss/fs.go index 2c1b2fb..d82f6ae 100644 --- a/layouts/simplecss/fs.go +++ b/layouts/simplecss/fs.go @@ -2,5 +2,6 @@ package simplecss import "embed" -//go:embed *.html +//go:embed templates/*.html +//go:embed static/* var FS embed.FS diff --git a/layouts/simplecss/posts_list.html b/layouts/simplecss/posts_list.html deleted file mode 100644 index e6a77fe..0000000 --- a/layouts/simplecss/posts_list.html +++ /dev/null @@ -1,12 +0,0 @@ -{{ range .Posts }} - {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} - {{ .HTML }} - {{ format_date .Post.PublishedAt }} - {{ if .Categories }} -

- {{ range .Categories }} - {{ .Name }} - {{ end }} -

- {{ end }} -{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/posts_single.html b/layouts/simplecss/posts_single.html deleted file mode 100644 index cda9bb2..0000000 --- a/layouts/simplecss/posts_single.html +++ /dev/null @@ -1,10 +0,0 @@ -{{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} -{{ .HTML }} -{{ format_date .Post.PublishedAt }} -{{ if .Categories }} -

- {{ range .Categories }} - {{ .Name }} - {{ end }} -

-{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/static/style.css b/layouts/simplecss/static/style.css new file mode 100644 index 0000000..cdfc4c2 --- /dev/null +++ b/layouts/simplecss/static/style.css @@ -0,0 +1,55 @@ +.h-entry { + margin-block-start: 1.5rem; + margin-block-end: 2.5rem; +} + +.post-meta { + display: flex; + flex-direction: row; + justify-content: space-between; + font-size: 0.95rem; +} + +.post-meta a { + color: var(--text-light); + text-decoration: none; +} + +.post-meta a:hover { + text-decoration: underline; +} + +.post-categories { + display: inline-flex; + gap: 0.5rem; +} + +.post-categories a:before { + content: "#"; +} + +/* Category list */ + +ul.category-list { + list-style: none; + padding-inline-start: 0; +} + +ul.category-list li { + display: flex; + flex-direction: row; + + justify-content: start; + gap: 4rem; +} + +ul.category-list span.category-list-name { + min-width: 15vw; +} + +/* Category single */ + +.category-description { + margin-block-start: 1.5rem; + margin-block-end: 2.5rem; +} \ No newline at end of file diff --git a/layouts/simplecss/templates/_post_meta.html b/layouts/simplecss/templates/_post_meta.html new file mode 100644 index 0000000..a042f41 --- /dev/null +++ b/layouts/simplecss/templates/_post_meta.html @@ -0,0 +1,10 @@ + \ No newline at end of file diff --git a/layouts/simplecss/templates/categories_list.html b/layouts/simplecss/templates/categories_list.html new file mode 100644 index 0000000..e5fc8c8 --- /dev/null +++ b/layouts/simplecss/templates/categories_list.html @@ -0,0 +1,9 @@ +

Categories

+
    +{{ range .Categories }} +
  • + {{ .Name }} ({{ .PostCount }}) + {{ if .DescriptionBrief }}{{ .DescriptionBrief }}{{ end }} +
  • +{{ end }} +
\ No newline at end of file diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html new file mode 100644 index 0000000..deaeb02 --- /dev/null +++ b/layouts/simplecss/templates/categories_single.html @@ -0,0 +1,11 @@ +

{{ .Category.Name }}

+{{ if .DescriptionHTML }} +
{{ .DescriptionHTML }}
+{{ end }} +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
+{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/layout_main.html b/layouts/simplecss/templates/layout_main.html similarity index 89% rename from layouts/simplecss/layout_main.html rename to layouts/simplecss/templates/layout_main.html index cc2e616..4aa5199 100644 --- a/layouts/simplecss/layout_main.html +++ b/layouts/simplecss/templates/layout_main.html @@ -7,6 +7,7 @@ +
diff --git a/layouts/simplecss/templates/posts_list.html b/layouts/simplecss/templates/posts_list.html new file mode 100644 index 0000000..5f10f1e --- /dev/null +++ b/layouts/simplecss/templates/posts_list.html @@ -0,0 +1,8 @@ +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + + {{ template "_post_meta.html" . }} +
+{{ end }} \ No newline at end of file diff --git a/layouts/simplecss/templates/posts_single.html b/layouts/simplecss/templates/posts_single.html new file mode 100644 index 0000000..8895b19 --- /dev/null +++ b/layouts/simplecss/templates/posts_single.html @@ -0,0 +1,5 @@ +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
\ No newline at end of file diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 346f77c..1a4275d 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -6,7 +6,9 @@ import ( "fmt" "html/template" "io" + "io/fs" "iter" + "log" "os" "path/filepath" "strings" @@ -31,11 +33,15 @@ type Builder struct { func New(site pubmodel.Site, opts Options) (*Builder, error) { tmpls, err := template.New(""). Funcs(templateFns(site, opts)). - ParseFS(opts.TemplatesFS, tmplNamePostSingle, tmplNamePostList, tmplNameLayoutMain, tmplNameCategoryList, tmplNameCategorySingle) + ParseFS(opts.TemplatesFS, "*.html") if err != nil { return nil, err } + for _, t := range tmpls.Templates() { + log.Printf("Loaded template %s", t.Name()) + } + return &Builder{ site: site, opts: opts, @@ -109,6 +115,9 @@ func (b *Builder) BuildSite(outDir string) error { return b.writeUploads(buildCtx, b.site.Uploads) }) + // Build static assets + eg.Go(func() error { return b.writeStaticAssets(buildCtx) }) + return eg.Wait() } @@ -432,7 +441,7 @@ func (b *Builder) renderTemplate(w io.Writer, name string, data interface{}) err func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error { for _, u := range uploads { - fullPath := filepath.Join(ctx.outDir, "uploads", u.Slug) + fullPath := filepath.Join(ctx.outDir, b.opts.BaseUploads, u.Slug) if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { return err } @@ -460,3 +469,37 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error } return nil } + +func (b *Builder) writeStaticAssets(ctx buildContext) error { + return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } else if d.IsDir() { + return nil + } + + fullPath := filepath.Join(ctx.outDir, b.opts.BaseStatic, path) + if err := os.MkdirAll(filepath.Dir(fullPath), 0755); err != nil { + return err + } + + return func() error { + r, err := b.opts.StaticFS.Open(path) + if err != nil { + return err + } + defer r.Close() + + w, err := os.Create(fullPath) + if err != nil { + return err + } + defer w.Close() + + if _, err := io.Copy(w, r); err != nil { + return err + } + return nil + }() + }) +} diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index 2152290..cea02f5 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -29,12 +29,17 @@ const ( ) type Options struct { - // BasePosts is the base path for posts. - BasePosts string + BasePosts string // BasePosts is the base path for posts. + BaseUploads string // BaseUploads is the base path for uploads. + BaseStatic string // BaseStatic is the base path for static assets. // TemplatesFS provides the raw templates for rendering the site. TemplatesFS fs.FS + // StaticFS provides the raw assets for the site. This will be written as is + // from the BaseStatic dir. + StaticFS fs.FS + // FeedItems holds the number of posts to show in the feed. FeedItems int diff --git a/services/publisher/service.go b/services/publisher/service.go index 2ed9046..939817a 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -3,6 +3,7 @@ package publisher import ( "context" "io" + "io/fs" "iter" "log" "os" @@ -102,9 +103,22 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ renderTZ = time.UTC } + templateFS, err := fs.Sub(simplecss.FS, "templates") + if err != nil { + return err + } + + staticFS, err := fs.Sub(simplecss.FS, "static") + if err != nil { + return err + } + sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ BasePosts: "/posts", - TemplatesFS: simplecss.FS, + BaseUploads: "/uploads", + BaseStatic: "/static", + TemplatesFS: templateFS, + StaticFS: staticFS, FeedItems: 30, RenderTZ: renderTZ, }) diff --git a/views/categories/index.html b/views/categories/index.html index 026d919..2d17beb 100644 --- a/views/categories/index.html +++ b/views/categories/index.html @@ -5,7 +5,7 @@
- {{ range .categories }} + {{ if .categories }} @@ -15,11 +15,13 @@ - - - - - + {{ range .categories }} + + + + + + {{ end }}
{{ .Name }}{{ .Slug }}{{ .PostCount }}
{{ .Name }}{{ .Slug }}{{ .PostCount }}
{{ else }} From 4d96ec8b95711e2eb7533e48eac7b40f9c41505b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 12:46:43 +1100 Subject: [PATCH 23/62] Add paging feature design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-22-paging-design.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-paging-design.md diff --git a/docs/superpowers/specs/2026-03-22-paging-design.md b/docs/superpowers/specs/2026-03-22-paging-design.md new file mode 100644 index 0000000..80e3ee6 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-paging-design.md @@ -0,0 +1,100 @@ +# Paging Feature Design + +## Overview + +Introduce offset-based pagination to the admin post list and the generated static site (both post listings and category listings). + +## Data Layer + +### New `sites` column + +Add `posts_per_page INTEGER NOT NULL DEFAULT 10` to the `sites` table. This setting controls the number of posts per page on the **generated static site only**. + +### New SQL queries + +- `CountPostsOfSite(siteID, showDeleted)` — returns total post count for the site +- `CountPostsOfCategory(categoryID)` — returns total published post count for a category + +### Model changes + +**`models.Site`** — add field: +```go +PostsPerPage int +``` + +**New shared type** (`models/paging.go`): +```go +type PageInfo struct { + CurrentPage int + TotalPages int + PostsPerPage int +} +``` + +Existing `db.PagingParams` and queries (`SelectPostsOfSite`, `SelectPostsOfCategory`) already support `LIMIT/OFFSET` and remain unchanged. + +## Admin Section + +### Post list pagination + +- **Page size: hardcoded at 25** (not tied to the `PostsPerPage` site setting) +- Handler (`handlers/posts.go` `Index()`) reads a `page` query parameter (default 1) +- Computes offset as `(page - 1) * 25` +- Fetches total post count via new `CountPosts()` service method to build `PageInfo` +- Passes `PageInfo` to template + +### Service changes + +- `ListPosts()` accepts paging params from the handler instead of hardcoding them +- New `CountPosts()` method that calls the count query + +### Template (`views/posts/index.html`) + +- Full numbered pagination with Previous/Next below the post list: `< 1 2 3 ... 10 >` +- Preserves existing query params (e.g. `?filter=deleted`) when paginating +- Both regular post list and trash view are paginated + +### Site settings form + +- Add "Posts per page" number input to `views/sitesettings/general.html` +- Add `PostsPerPage` field to `UpdateSiteSettingsParams` +- Server-side validation: minimum 1, maximum 100 + +## Generated Static Site + +### URL structure + +Post listing pages: +- `/posts/` — page 1 +- `/posts/page/2/` — page 2 +- `/posts/page/N/` — page N + +Category listing pages: +- `/categories//` — page 1 +- `/categories//page/2/` — page 2 +- `/categories//page/N/` — page N + +### Site root + +`/` (site root) shows the same content as `/posts/` (page 1 of all posts). + +### Builder changes (`providers/sitebuilder/builder.go`) + +- Instead of rendering one `posts_list.html` with all posts, generate multiple page files +- Uses `site.PostsPerPage` from the site setting to determine page size +- Same pattern for category pages + +### Publisher changes (`services/publisher/iter.go`) + +- Existing iterator fetches posts in batches of 50 internally — this stays as-is +- The builder chunks posts into pages of `PostsPerPage` size and renders each page as a separate HTML file + +### Template (`layouts/simplecss/templates/posts_list.html`) + +- Receives `PageInfo` plus the posts for that page +- Renders **Previous / Next** links only (no numbered pagination) +- Previous link hidden on page 1; Next link hidden on last page + +## Approach + +Offset-based pagination using the existing `db.PagingParams` infrastructure. Page number maps to offset: `offset = (page - 1) * postsPerPage`. From 7c4dc0885e444f7ce95571dd572765e90f9bf87d Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 13:12:28 +1100 Subject: [PATCH 24/62] Add paging implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/plans/2026-03-22-paging.md | 888 ++++++++++++++++++++ 1 file changed, 888 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-paging.md diff --git a/docs/superpowers/plans/2026-03-22-paging.md b/docs/superpowers/plans/2026-03-22-paging.md new file mode 100644 index 0000000..9b44775 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-paging.md @@ -0,0 +1,888 @@ +# Paging Feature Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add offset-based pagination to the admin post list and the generated static site (posts and category listings). + +**Architecture:** Add a `posts_per_page` column to the `sites` table for configurable page size on the generated site. Admin uses a hardcoded page size of 25. The existing `db.PagingParams` and `LIMIT/OFFSET` SQL infrastructure is reused. A shared `models.PageInfo` type carries pagination state to templates. + +**Tech Stack:** Go, SQLite, sqlc, Fiber v3, html/template, Bootstrap + +--- + +### Task 1: Add `posts_per_page` column and regenerate sqlc + +**Files:** +- Create: `sql/schema/05_posts_per_page.up.sql` +- Modify: `sql/queries/sites.sql:10-19` (InsertSite query) +- Modify: `sql/queries/sites.sql:24-25` (UpdateSite query) +- Regenerate: `providers/db/gen/sqlgen/` (sqlc output) + +- [ ] **Step 1: Create migration file** + +Create `sql/schema/05_posts_per_page.up.sql`: +```sql +ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; +``` + +- [ ] **Step 2: Update the InsertSite SQL query** + +In `sql/queries/sites.sql`, update the InsertSite query (lines 10-19) to include `posts_per_page`: +```sql +-- name: InsertSite :one +INSERT INTO sites ( + owner_id, + guid, + title, + tagline, + timezone, + posts_per_page, + created_at +) VALUES (?, ?, ?, ?, ?, ?, ?) +RETURNING id; +``` + +- [ ] **Step 3: Update the UpdateSite SQL query** + +In `sql/queries/sites.sql`, update line 24-25: +```sql +-- name: UpdateSite :exec +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; +``` + +- [ ] **Step 4: Regenerate sqlc** + +Run: `sqlc generate` +Expected: `providers/db/gen/sqlgen/` files updated with new `PostsPerPage` field on `Site` struct, updated `InsertSiteParams` and `UpdateSiteParams`. + +- [ ] **Step 5: Run tests to verify nothing broke** + +Run: `go test ./...` +Expected: All existing tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add sql/schema/05_posts_per_page.up.sql sql/queries/sites.sql providers/db/gen/sqlgen/ +git commit -m "feat: add posts_per_page column to sites table" +``` + +--- + +### Task 2: Update Site model and DB provider for `PostsPerPage` + +**Files:** +- Modify: `models/sites.go:24-33` (Site struct) +- Modify: `providers/db/sites.go:42-65` (SaveSite) +- Modify: `providers/db/sites.go:102-112` (dbSiteToSite) + +- [ ] **Step 1: Add `PostsPerPage` to `models.Site`** + +In `models/sites.go`, add to the `Site` struct (after `Timezone`): +```go +PostsPerPage int +``` + +- [ ] **Step 2: Update `dbSiteToSite` in `providers/db/sites.go`** + +In `providers/db/sites.go`, update `dbSiteToSite` (line 102) to map the new field: +```go +func dbSiteToSite(row sqlgen.Site) models.Site { + return models.Site{ + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Timezone: row.Timezone, + Tagline: row.Tagline, + PostsPerPage: int(row.PostsPerPage), + Created: time.Unix(row.CreatedAt, 0).UTC(), + } +} +``` + +- [ ] **Step 3: Update `SaveSite` to include `PostsPerPage`** + +In `providers/db/sites.go`, update the `InsertSite` call (line 44) to include `PostsPerPage`: +```go +newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ + OwnerID: site.OwnerID, + Guid: site.GUID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + CreatedAt: timeToInt(site.Created), +}) +``` + +Update the `UpdateSite` call (line 59) to include `PostsPerPage`: +```go +return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + ID: site.ID, +}) +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add models/sites.go providers/db/sites.go sql/queries/sites.sql providers/db/gen/sqlgen/ +git commit -m "feat: add PostsPerPage to Site model and DB provider" +``` + +--- + +### Task 3: Add `CountPostsOfSite` SQL query and DB method + +**Files:** +- Modify: `sql/queries/posts.sql` (add count query) +- Modify: `providers/db/posts.go` (add CountPostsOfSite method) +- Modify: `providers/db/provider_test.go` (add test) +- Regenerate: `providers/db/gen/sqlgen/` + +- [ ] **Step 1: Write the failing test** + +Add to `providers/db/provider_test.go` inside `TestProvider_Posts`: +```go +t.Run("count posts of site", func(t *testing.T) { + countSite := &models.Site{ + OwnerID: user.ID, + GUID: models.NewNanoID(), + Title: "Count Blog", + } + require.NoError(t, p.SaveSite(ctx, countSite)) + + now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC) + for i := 0; i < 3; i++ { + post := &models.Post{ + SiteID: countSite.ID, + GUID: models.NewNanoID(), + Title: fmt.Sprintf("Post %d", i), + Body: "body", + Slug: fmt.Sprintf("/post-%d", i), + CreatedAt: now, + } + require.NoError(t, p.SavePost(ctx, post)) + } + + count, err := p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(3), count) + + // Soft-delete one post + posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0}) + require.NoError(t, err) + require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID)) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` +Expected: FAIL — `CountPostsOfSite` method does not exist. + +- [ ] **Step 3: Add SQL query** + +Add to `sql/queries/posts.sql`: +```sql +-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = sqlc.arg(site_id) AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +); +``` + +Run: `sqlc generate` + +- [ ] **Step 4: Add DB provider method** + +Add to `providers/db/posts.go`: +```go +func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) { + filter := "active" + if showDeleted { + filter = "deleted" + } + return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{ + SiteID: siteID, + PostFilter: filter, + }) +} +``` + +Note: check the generated `sqlgen.CountPostsOfSiteParams` struct name and fields after `sqlc generate` — adjust if the field names differ. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `go test ./providers/db/ -run TestProvider_Posts/count_posts_of_site -v` +Expected: PASS + +- [ ] **Step 6: Run all tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add sql/queries/posts.sql providers/db/posts.go providers/db/provider_test.go providers/db/gen/sqlgen/ +git commit -m "feat: add CountPostsOfSite query and DB method" +``` + +--- + +### Task 4: Add `models.PageInfo` type + +**Files:** +- Create: `models/paging.go` + +- [ ] **Step 1: Create `models/paging.go`** + +```go +package models + +// PageInfo carries pagination state for templates. +type PageInfo struct { + CurrentPage int + TotalPages int + PostsPerPage int +} + +// HasPrevious returns true if there is a previous page. +func (p PageInfo) HasPrevious() bool { + return p.CurrentPage > 1 +} + +// HasNext returns true if there is a next page. +func (p PageInfo) HasNext() bool { + return p.CurrentPage < p.TotalPages +} + +// PreviousPage returns the previous page number. +func (p PageInfo) PreviousPage() int { + return p.CurrentPage - 1 +} + +// NextPage returns the next page number. +func (p PageInfo) NextPage() int { + return p.CurrentPage + 1 +} +``` + +- [ ] **Step 2: Run tests** + +Run: `go test ./...` +Expected: All pass (no tests yet for this type, but it should compile). + +- [ ] **Step 3: Commit** + +```bash +git add models/paging.go +git commit -m "feat: add PageInfo model for pagination" +``` + +--- + +### Task 5: Add pagination to admin post list (service + handler) + +**Files:** +- Modify: `services/posts/list.go:15-38` (ListPosts signature and implementation) +- Modify: `handlers/posts.go:18-39` (Index handler) + +- [ ] **Step 1: Update `ListPosts` to accept paging params and return count** + +Replace `services/posts/list.go` `ListPosts` method: +```go +type ListPostsResult struct { + Posts []*PostWithCategories + TotalCount int64 +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { + site, ok := models.GetSite(ctx) + if !ok { + return ListPostsResult{}, models.SiteRequiredError + } + + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) + if err != nil { + return ListPostsResult{}, err + } + + count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) + if err != nil { + return ListPostsResult{}, err + } + + result := make([]*PostWithCategories, len(posts)) + for i, post := range posts { + cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) + if err != nil { + return ListPostsResult{}, err + } + result[i] = &PostWithCategories{Post: post, Categories: cats} + } + return ListPostsResult{Posts: result, TotalCount: count}, nil +} +``` + +- [ ] **Step 2: Update the admin handler** + +Replace `handlers/posts.go` `Index` method: +```go +func (ph PostsHandler) Index(c fiber.Ctx) error { + var req struct { + Filter string `query:"filter"` + Page int `query:"page"` + } + if err := c.Bind().Query(&req); err != nil { + return fiber.ErrBadRequest + } + + const perPage = 25 + if req.Page < 1 { + req.Page = 1 + } + + result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{ + Offset: int64((req.Page - 1) * perPage), + Limit: perPage, + }) + if err != nil { + return err + } + + totalPages := int(result.TotalCount+int64(perPage)-1) / perPage + if totalPages < 1 { + totalPages = 1 + } + + pageInfo := models.PageInfo{ + CurrentPage: req.Page, + TotalPages: totalPages, + PostsPerPage: perPage, + } + + return accepts(c, json(func() any { + return result.Posts + }), html(func(c fiber.Ctx) error { + return c.Render("posts/index", fiber.Map{ + "req": req, + "posts": result.Posts, + "pageInfo": pageInfo, + }) + })) +} +``` + +Note: add `"lmika.dev/lmika/weiro/providers/db"` and `"lmika.dev/lmika/weiro/models"` to imports in `handlers/posts.go`. + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./...` +Expected: Compiles successfully. + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add services/posts/list.go handlers/posts.go +git commit -m "feat: add pagination to admin post list handler and service" +``` + +--- + +### Task 6: Add pagination UI to admin post list template + +**Files:** +- Modify: `views/posts/index.html` + +- [ ] **Step 1: Add pagination controls to admin template** + +Add pagination controls after the post list in `views/posts/index.html`. Insert before the closing `
` tag: + +```html +{{ if gt .pageInfo.TotalPages 1 }} + +{{ end }} +``` + +- [ ] **Step 2: Add `Pages` method to `PageInfo`** + +Add to `models/paging.go`: +```go +// Pages returns a slice of page numbers for rendering numbered pagination. +func (p PageInfo) Pages() []int { + pages := make([]int, p.TotalPages) + for i := range pages { + pages[i] = i + 1 + } + return pages +} +``` + +- [ ] **Step 3: Verify it compiles and test manually** + +Run: `go build ./...` +Expected: Compiles. + +- [ ] **Step 4: Commit** + +```bash +git add views/posts/index.html models/paging.go +git commit -m "feat: add pagination controls to admin post list" +``` + +--- + +### Task 7: Add site settings form for `PostsPerPage` + +**Files:** +- Modify: `views/sitesettings/general.html:17-48` (form) +- Modify: `services/sites/services.go:131-158` (UpdateSiteSettingsParams and UpdateSiteSettings) + +- [ ] **Step 1: Add `PostsPerPage` to `UpdateSiteSettingsParams`** + +In `services/sites/services.go`, update the struct (line 131): +```go +type UpdateSiteSettingsParams struct { + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` + PostsPerPage int `form:"postsPerPage"` +} +``` + +- [ ] **Step 2: Update `UpdateSiteSettings` to handle `PostsPerPage`** + +In `services/sites/services.go`, update `UpdateSiteSettings` (line 138) to validate and set the new field: +```go +func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { + site, err := s.GetSiteByID(ctx, params.SiteID) + if err != nil { + return models.Site{}, err + } + + _, err = time.LoadLocation(params.Timezone) + if err != nil { + return models.Site{}, errors.Wrap(err, "invalid timezone") + } + + postsPerPage := params.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 1 + } else if postsPerPage > 100 { + postsPerPage = 100 + } + + site.Title = params.Name + site.Tagline = params.Tagline + site.Timezone = params.Timezone + site.PostsPerPage = postsPerPage + + if err := s.db.SaveSite(ctx, &site); err != nil { + return models.Site{}, err + } + + return site, nil +} +``` + +- [ ] **Step 3: Add form field to settings template** + +In `views/sitesettings/general.html`, add after the Timezone field (after line 43, before the submit button row): +```html +
+ +
+ +
Number of posts per page on the generated site.
+
+
+``` + +- [ ] **Step 4: Verify it compiles** + +Run: `go build ./...` +Expected: Compiles. + +- [ ] **Step 5: Commit** + +```bash +git add services/sites/services.go views/sitesettings/general.html +git commit -m "feat: add posts per page setting to site settings" +``` + +--- + +### Task 8: Add pagination to generated site post list + +**Files:** +- Modify: `providers/sitebuilder/tmpls.go:62-65` (postListData) +- Modify: `providers/sitebuilder/builder.go:124-146` (renderPostListWithCategories) +- Modify: `layouts/simplecss/templates/posts_list.html` + +- [ ] **Step 1: Update `postListData` to include `PageInfo`** + +In `providers/sitebuilder/tmpls.go`, update `postListData` (line 62): +```go +type postListData struct { + commonData + Posts []postSingleData + PageInfo models.PageInfo + PrevURL string + NextURL string +} +``` + +- [ ] **Step 2: Rewrite `renderPostListWithCategories` to paginate** + +Replace `renderPostListWithCategories` in `providers/sitebuilder/builder.go` (line 124): +```go +func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { + // Collect all posts + var allPosts []postSingleData + for mp := range b.site.PostIter(ctx) { + post, err := mp.Get() + if err != nil { + return err + } + rp, err := b.renderPostWithCategories(ctx, post) + if err != nil { + return err + } + allPosts = append(allPosts, rp) + } + + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 + } + + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 + } + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = "/posts/" + } else { + prevURL = fmt.Sprintf("/posts/page/%d/", page-1) + } + } + if page < totalPages { + nextURL = fmt.Sprintf("/posts/page/%d/", page+1) + } + + pl := postListData{ + commonData: commonData{Site: b.site}, + Posts: allPosts[start:end], + PageInfo: pageInfo, + PrevURL: prevURL, + NextURL: nextURL, + } + + // Determine output path(s) for this page + var paths []string + if page == 1 { + // Page 1 renders at both root and /posts/ + paths = []string{"", "/posts"} + } else { + paths = []string{fmt.Sprintf("/posts/page/%d", page)} + } + + for _, path := range paths { + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePostList, pl) + }); err != nil { + return err + } + } + } + + return nil +} +``` + +- [ ] **Step 3: Update the post list template with prev/next links** + +Replace `layouts/simplecss/templates/posts_list.html`: +```html +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
+{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} +``` + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: Existing builder test may need updating (see next step). + +- [ ] **Step 5: Update builder test** + +The test in `providers/sitebuilder/builder_test.go` creates a `pubmodel.Site` without `PostsPerPage`, which will default to 0. Update the test site to set `PostsPerPage`: +```go +site := pubmodel.Site{ + Site: models.Site{PostsPerPage: 10}, + BaseURL: "https://example.com", + PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { + // ... existing code ... + }, +} +``` + +The expected `index.html` content stays the same since both posts fit on one page. + +- [ ] **Step 6: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 7: Commit** + +```bash +git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/posts_list.html providers/sitebuilder/builder_test.go +git commit -m "feat: add pagination to generated site post list" +``` + +--- + +### Task 9: Add pagination to generated site category pages + +**Files:** +- Modify: `providers/sitebuilder/tmpls.go:82-88` (categorySingleData) +- Modify: `providers/sitebuilder/builder.go:315-362` (renderCategoryPages) +- Modify: `layouts/simplecss/templates/categories_single.html` + +- [ ] **Step 1: Update `categorySingleData` to include pagination** + +In `providers/sitebuilder/tmpls.go`, update `categorySingleData` (line 82): +```go +type categorySingleData struct { + commonData + Category *models.Category + DescriptionHTML template.HTML + Posts []postSingleData + Path string + PageInfo models.PageInfo + PrevURL string + NextURL string +} +``` + +- [ ] **Step 2: Rewrite `renderCategoryPages` to paginate** + +Replace `renderCategoryPages` in `providers/sitebuilder/builder.go` (line 315): +```go +func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) error { + for _, cwc := range b.site.Categories { + if cwc.PostCount == 0 { + continue + } + + // Collect all posts for this category + var allPosts []postSingleData + for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) { + post, err := mp.Get() + if err != nil { + return err + } + rp, err := b.renderPostWithCategories(goCtx, post) + if err != nil { + return err + } + allPosts = append(allPosts, rp) + } + + var descHTML bytes.Buffer + if cwc.Description != "" { + if err := b.mdRenderer.RenderTo(goCtx, &descHTML, cwc.Description); err != nil { + return err + } + } + + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 + } + + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 + } + + basePath := fmt.Sprintf("/categories/%s", cwc.Slug) + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = basePath + "/" + } else { + prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1) + } + } + if page < totalPages { + nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1) + } + + path := basePath + if page > 1 { + path = fmt.Sprintf("%s/page/%d", basePath, page) + } + + data := categorySingleData{ + commonData: commonData{Site: b.site}, + Category: &cwc.Category, + DescriptionHTML: template.HTML(descHTML.String()), + Posts: allPosts[start:end], + Path: path, + PageInfo: pageInfo, + PrevURL: prevURL, + NextURL: nextURL, + } + + if err := b.createAtPath(ctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNameCategorySingle, data) + }); err != nil { + return err + } + } + + // Per-category feeds (use all posts, not paginated) + if err := b.renderCategoryFeed(ctx, cwc, allPosts); err != nil { + return err + } + } + + return nil +} +``` + +- [ ] **Step 3: Update category single template with prev/next links** + +Replace `layouts/simplecss/templates/categories_single.html`: +```html +{{ if .DescriptionHTML }}
{{ .DescriptionHTML }}
{{ end }} +{{ range .Posts }} +
+ {{ if .Post.Title }}

{{ .Post.Title }}

{{ end }} + {{ .HTML }} + {{ template "_post_meta.html" . }} +
+{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} +``` + +Note: check the current content of `categories_single.html` first — preserve any existing structure (like `

` headings) that may not have been captured in the exploration. Read the file before editing. + +- [ ] **Step 4: Run tests** + +Run: `go test ./...` +Expected: All pass. + +- [ ] **Step 5: Commit** + +```bash +git add providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/categories_single.html +git commit -m "feat: add pagination to generated site category pages" +``` + +--- + +### Task 10: Final verification + +- [ ] **Step 1: Run full test suite** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 2: Build the project** + +Run: `go build ./...` +Expected: Clean build with no errors. + +- [ ] **Step 3: Commit any remaining changes** + +If any files were missed, stage and commit them. From 9b36a35c1a0ef455376482c6c63d8405cfac2892 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:26:09 +1100 Subject: [PATCH 25/62] feat: add posts_per_page column to sites table Co-Authored-By: Claude Sonnet 4.6 --- providers/db/gen/sqlgen/models.go | 15 ++++++----- providers/db/gen/sqlgen/sites.sql.go | 38 +++++++++++++++++----------- sql/queries/sites.sql | 5 ++-- sql/schema/05_posts_per_page.up.sql | 1 + 4 files changed, 35 insertions(+), 24 deletions(-) create mode 100644 sql/schema/05_posts_per_page.up.sql diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 788c292..ae58594 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -57,13 +57,14 @@ type PublishTarget struct { } type Site struct { - ID int64 - OwnerID int64 - Guid string - Title string - Tagline string - CreatedAt int64 - Timezone string + ID int64 + OwnerID int64 + Guid string + Title string + Tagline string + CreatedAt int64 + Timezone string + PostsPerPage int64 } type Upload struct { diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index bd80fb3..80ccbc0 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -28,18 +28,20 @@ INSERT INTO sites ( title, tagline, timezone, + posts_per_page, created_at -) VALUES (?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id ` type InsertSiteParams struct { - OwnerID int64 - Guid string - Title string - Tagline string - Timezone string - CreatedAt int64 + OwnerID int64 + Guid string + Title string + Tagline string + Timezone string + PostsPerPage int64 + CreatedAt int64 } func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) { @@ -49,6 +51,7 @@ func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, arg.Title, arg.Tagline, arg.Timezone, + arg.PostsPerPage, arg.CreatedAt, ) var id int64 @@ -101,7 +104,7 @@ func (q *Queries) SelectAllSitesWithOwners(ctx context.Context) ([]SelectAllSite } const selectSiteByGUID = `-- name: SelectSiteByGUID :one -SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE guid = ? +SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE guid = ? ` func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, error) { @@ -115,12 +118,13 @@ func (q *Queries) SelectSiteByGUID(ctx context.Context, guid string) (Site, erro &i.Tagline, &i.CreatedAt, &i.Timezone, + &i.PostsPerPage, ) return i, err } const selectSiteByID = `-- name: SelectSiteByID :one -SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE id = ? +SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE id = ? ` func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { @@ -134,12 +138,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) { &i.Tagline, &i.CreatedAt, &i.Timezone, + &i.PostsPerPage, ) return i, err } const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many -SELECT id, owner_id, guid, title, tagline, created_at, timezone FROM sites WHERE owner_id = ? ORDER BY title ASC +SELECT id, owner_id, guid, title, tagline, created_at, timezone, posts_per_page FROM sites WHERE owner_id = ? ORDER BY title ASC ` func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) { @@ -159,6 +164,7 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] &i.Tagline, &i.CreatedAt, &i.Timezone, + &i.PostsPerPage, ); err != nil { return nil, err } @@ -174,14 +180,15 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([] } const updateSite = `-- name: UpdateSite :exec -UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ? +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ? ` type UpdateSiteParams struct { - Title string - Tagline string - Timezone string - ID int64 + Title string + Tagline string + Timezone string + PostsPerPage int64 + ID int64 } func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error { @@ -189,6 +196,7 @@ func (q *Queries) UpdateSite(ctx context.Context, arg UpdateSiteParams) error { arg.Title, arg.Tagline, arg.Timezone, + arg.PostsPerPage, arg.ID, ) return err diff --git a/sql/queries/sites.sql b/sql/queries/sites.sql index 8fe2469..0609b12 100644 --- a/sql/queries/sites.sql +++ b/sql/queries/sites.sql @@ -14,15 +14,16 @@ INSERT INTO sites ( title, tagline, timezone, + posts_per_page, created_at -) VALUES (?, ?, ?, ?, ?, ?) +) VALUES (?, ?, ?, ?, ?, ?, ?) RETURNING id; -- name: HasUsersAndSites :one SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites; -- name: UpdateSite :exec -UPDATE sites SET title = ?, tagline = ?, timezone = ? WHERE id = ?; +UPDATE sites SET title = ?, tagline = ?, timezone = ?, posts_per_page = ? WHERE id = ?; -- name: SelectAllSitesWithOwners :many SELECT s.id, s.guid, s.title, s.owner_id, u.username diff --git a/sql/schema/05_posts_per_page.up.sql b/sql/schema/05_posts_per_page.up.sql new file mode 100644 index 0000000..1bea8f9 --- /dev/null +++ b/sql/schema/05_posts_per_page.up.sql @@ -0,0 +1 @@ +ALTER TABLE sites ADD COLUMN posts_per_page INTEGER NOT NULL DEFAULT 10; From 9919f3444ad9d3a073eb08ed45ba4c6b1463f944 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:28:50 +1100 Subject: [PATCH 26/62] feat: add PostsPerPage to Site model and DB provider Co-Authored-By: Claude Sonnet 4.6 --- models/sites.go | 7 ++++--- providers/db/sites.go | 37 ++++++++++++++++++++----------------- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/models/sites.go b/models/sites.go index 16cbef4..81bf6be 100644 --- a/models/sites.go +++ b/models/sites.go @@ -27,9 +27,10 @@ type Site struct { GUID string Created time.Time - Title string - Tagline string - Timezone string + Title string + Tagline string + Timezone string + PostsPerPage int } type SitePublishTarget struct { diff --git a/providers/db/sites.go b/providers/db/sites.go index 28d83f6..d1167ca 100644 --- a/providers/db/sites.go +++ b/providers/db/sites.go @@ -42,12 +42,13 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ( func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { if site.ID == 0 { newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{ - OwnerID: site.OwnerID, - Guid: site.GUID, - Title: site.Title, - Tagline: site.Tagline, - Timezone: site.Timezone, - CreatedAt: timeToInt(site.Created), + OwnerID: site.OwnerID, + Guid: site.GUID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + CreatedAt: timeToInt(site.Created), }) if err != nil { return err @@ -57,10 +58,11 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error { } return db.queries.UpdateSite(ctx, sqlgen.UpdateSiteParams{ - Title: site.Title, - Tagline: site.Tagline, - Timezone: site.Timezone, - ID: site.ID, + Title: site.Title, + Tagline: site.Tagline, + Timezone: site.Timezone, + PostsPerPage: int64(site.PostsPerPage), + ID: site.ID, }) } @@ -101,12 +103,13 @@ func (db *Provider) SelectAllSitesWithOwners(ctx context.Context) ([]SiteWithOwn func dbSiteToSite(row sqlgen.Site) models.Site { return models.Site{ - ID: row.ID, - OwnerID: row.OwnerID, - GUID: row.Guid, - Title: row.Title, - Timezone: row.Timezone, - Tagline: row.Tagline, - Created: time.Unix(row.CreatedAt, 0).UTC(), + ID: row.ID, + OwnerID: row.OwnerID, + GUID: row.Guid, + Title: row.Title, + Timezone: row.Timezone, + Tagline: row.Tagline, + PostsPerPage: int(row.PostsPerPage), + Created: time.Unix(row.CreatedAt, 0).UTC(), } } From 5bf77ede5c6e0631de299b38d13dea4205864020 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:30:57 +1100 Subject: [PATCH 27/62] feat: add CountPostsOfSite query and DB method Co-Authored-By: Claude Sonnet 4.6 --- providers/db/gen/sqlgen/posts.sql.go | 22 +++++++++++++++ providers/db/posts.go | 11 ++++++++ providers/db/provider_test.go | 40 ++++++++++++++++++++++++++++ sql/queries/posts.sql | 9 +++++++ 4 files changed, 82 insertions(+) diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 8bff191..ef3d170 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -9,6 +9,28 @@ import ( "context" ) +const countPostsOfSite = `-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = ?1 AND ( + CASE CAST (?2 AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +) +` + +type CountPostsOfSiteParams struct { + SiteID int64 + PostFilter string +} + +func (q *Queries) CountPostsOfSite(ctx context.Context, arg CountPostsOfSiteParams) (int64, error) { + row := q.db.QueryRowContext(ctx, countPostsOfSite, arg.SiteID, arg.PostFilter) + var count int64 + err := row.Scan(&count) + return count, err +} + const hardDeletePost = `-- name: HardDeletePost :exec DELETE FROM posts WHERE id = ? ` diff --git a/providers/db/posts.go b/providers/db/posts.go index 218e931..7f58d1a 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -13,6 +13,17 @@ type PagingParams struct { Offset int64 } +func (db *Provider) CountPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) (int64, error) { + filter := "active" + if showDeleted { + filter = "deleted" + } + return db.queries.CountPostsOfSite(ctx, sqlgen.CountPostsOfSiteParams{ + SiteID: siteID, + PostFilter: filter, + }) +} + func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool, pp PagingParams) ([]*models.Post, error) { var filter = "" if showDeleted { diff --git a/providers/db/provider_test.go b/providers/db/provider_test.go index 06f03c0..0a2e6df 100644 --- a/providers/db/provider_test.go +++ b/providers/db/provider_test.go @@ -3,6 +3,7 @@ package db_test import ( "context" "encoding/base64" + "fmt" "path/filepath" "testing" "time" @@ -229,6 +230,45 @@ func TestProvider_Posts(t *testing.T) { require.NoError(t, err) assert.Empty(t, posts) }) + + t.Run("count posts of site", func(t *testing.T) { + countSite := &models.Site{ + OwnerID: user.ID, + GUID: models.NewNanoID(), + Title: "Count Blog", + } + require.NoError(t, p.SaveSite(ctx, countSite)) + + now := time.Date(2026, 3, 22, 12, 0, 0, 0, time.UTC) + for i := 0; i < 3; i++ { + post := &models.Post{ + SiteID: countSite.ID, + GUID: models.NewNanoID(), + Title: fmt.Sprintf("Post %d", i), + Body: "body", + Slug: fmt.Sprintf("/post-%d", i), + CreatedAt: now, + } + require.NoError(t, p.SavePost(ctx, post)) + } + + count, err := p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(3), count) + + // Soft-delete one post + posts, err := p.SelectPostsOfSite(ctx, countSite.ID, false, db.PagingParams{Limit: 10, Offset: 0}) + require.NoError(t, err) + require.NoError(t, p.SoftDeletePost(ctx, posts[0].ID)) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, false) + require.NoError(t, err) + assert.Equal(t, int64(2), count) + + count, err = p.CountPostsOfSite(ctx, countSite.ID, true) + require.NoError(t, err) + assert.Equal(t, int64(1), count) + }) } func TestProvider_PublishTargets(t *testing.T) { diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index dae1f39..5a4c18e 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,3 +1,12 @@ +-- name: CountPostsOfSite :one +SELECT COUNT(*) FROM posts +WHERE site_id = sqlc.arg(site_id) AND ( + CASE CAST (sqlc.arg(post_filter) AS TEXT) + WHEN 'deleted' THEN deleted_at > 0 + ELSE deleted_at = 0 + END +); + -- name: SelectPostsOfSite :many SELECT * FROM posts From 113789a972358ae0f1e82acbea8586b074a74f2f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:32:02 +1100 Subject: [PATCH 28/62] feat: add PageInfo model for pagination Co-Authored-By: Claude Opus 4.6 --- models/paging.go | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 models/paging.go diff --git a/models/paging.go b/models/paging.go new file mode 100644 index 0000000..4ba0c9f --- /dev/null +++ b/models/paging.go @@ -0,0 +1,28 @@ +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 +} From 82feccf64aab94a2c85b7459aa40028d20e8a8fd Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:33:31 +1100 Subject: [PATCH 29/62] feat: add pagination to admin post list handler and service Co-Authored-By: Claude Opus 4.6 --- handlers/posts.go | 30 ++++++++++++++++++++++++++---- services/posts/list.go | 25 ++++++++++++++++--------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/handlers/posts.go b/handlers/posts.go index a133758..3326533 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -6,6 +6,7 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/posts" ) @@ -18,22 +19,43 @@ type PostsHandler struct { func (ph PostsHandler) Index(c fiber.Ctx) error { var req struct { Filter string `query:"filter"` + Page int `query:"page"` } if err := c.Bind().Query(&req); err != nil { return fiber.ErrBadRequest } - posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted") + const perPage = 25 + if req.Page < 1 { + req.Page = 1 + } + + result, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted", db.PagingParams{ + Offset: int64((req.Page - 1) * perPage), + Limit: perPage, + }) if err != nil { return err } + totalPages := int(result.TotalCount+int64(perPage)-1) / perPage + if totalPages < 1 { + totalPages = 1 + } + + pageInfo := models.PageInfo{ + CurrentPage: req.Page, + TotalPages: totalPages, + PostsPerPage: perPage, + } + return accepts(c, json(func() any { - return posts + return result.Posts }), html(func(c fiber.Ctx) error { return c.Render("posts/index", fiber.Map{ - "req": req, - "posts": posts, + "req": req, + "posts": result.Posts, + "pageInfo": pageInfo, }) })) } diff --git a/services/posts/list.go b/services/posts/list.go index 15e14d3..dd25bae 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -12,29 +12,36 @@ type PostWithCategories struct { Categories []*models.Category } -func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*PostWithCategories, error) { +type ListPostsResult struct { + Posts []*PostWithCategories + TotalCount int64 +} + +func (s *Service) ListPosts(ctx context.Context, showDeleted bool, paging db.PagingParams) (ListPostsResult, error) { site, ok := models.GetSite(ctx) if !ok { - return nil, models.SiteRequiredError + return ListPostsResult{}, models.SiteRequiredError } - posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, db.PagingParams{ - Offset: 0, - Limit: 25, - }) + posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted, paging) if err != nil { - return nil, err + return ListPostsResult{}, err + } + + count, err := s.db.CountPostsOfSite(ctx, site.ID, showDeleted) + if err != nil { + return ListPostsResult{}, err } result := make([]*PostWithCategories, len(posts)) for i, post := range posts { cats, err := s.db.SelectCategoriesOfPost(ctx, post.ID) if err != nil { - return nil, err + return ListPostsResult{}, err } result[i] = &PostWithCategories{Post: post, Categories: cats} } - return result, nil + return ListPostsResult{Posts: result, TotalCount: count}, nil } func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) { From d7a5d425b8361e2b5b2b381f773b5393fa43d7b8 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:34:55 +1100 Subject: [PATCH 30/62] feat: add pagination controls to admin post list Co-Authored-By: Claude Opus 4.6 --- models/paging.go | 9 +++++++++ views/posts/index.html | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/models/paging.go b/models/paging.go index 4ba0c9f..b4e514b 100644 --- a/models/paging.go +++ b/models/paging.go @@ -26,3 +26,12 @@ func (p PageInfo) PreviousPage() int { func (p PageInfo) NextPage() int { return p.CurrentPage + 1 } + +// Pages returns a slice of page numbers for rendering numbered pagination. +func (p PageInfo) Pages() []int { + pages := make([]int, p.TotalPages) + for i := range pages { + pages[i] = i + 1 + } + return pages +} diff --git a/views/posts/index.html b/views/posts/index.html index bbf445d..7786539 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -62,4 +62,22 @@ {{ end }}

{{ end }} + + {{ if gt .pageInfo.TotalPages 1 }} + + {{ end }} \ No newline at end of file From 550ebf728aaf4e6439be2da1016ef50abcd10d80 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:35:56 +1100 Subject: [PATCH 31/62] feat: add posts per page setting to site settings Co-Authored-By: Claude Sonnet 4.6 --- services/sites/services.go | 17 +++++++++++++---- views/sitesettings/general.html | 7 +++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/services/sites/services.go b/services/sites/services.go index 06afe15..4c974bb 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -129,10 +129,11 @@ func (s *Service) ListAllSitesWithOwners(ctx context.Context) ([]db.SiteWithOwne } type UpdateSiteSettingsParams struct { - SiteID int64 `form:"siteID"` - Name string `form:"name"` - Tagline string `form:"tagline"` - Timezone string `form:"timezone"` + SiteID int64 `form:"siteID"` + Name string `form:"name"` + Tagline string `form:"tagline"` + Timezone string `form:"timezone"` + PostsPerPage int `form:"postsPerPage"` } func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSettingsParams) (models.Site, error) { @@ -146,9 +147,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti return models.Site{}, errors.Wrap(err, "invalid timezone") } + postsPerPage := params.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 1 + } else if postsPerPage > 100 { + postsPerPage = 100 + } + site.Title = params.Name site.Tagline = params.Tagline site.Timezone = params.Timezone + site.PostsPerPage = postsPerPage if err := s.db.SaveSite(ctx, &site); err != nil { return models.Site{}, err diff --git a/views/sitesettings/general.html b/views/sitesettings/general.html index ca3e7a9..6f1833b 100644 --- a/views/sitesettings/general.html +++ b/views/sitesettings/general.html @@ -41,6 +41,13 @@
+
+ +
+ +
Number of posts per page on the generated site.
+
+
From 30884372d6db048c92be996181b1a90dd5066677 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:37:42 +1100 Subject: [PATCH 32/62] feat: add pagination to generated site post list Co-Authored-By: Claude Opus 4.6 --- layouts/simplecss/templates/posts_list.html | 8 ++- providers/sitebuilder/builder.go | 70 ++++++++++++++++++--- providers/sitebuilder/builder_test.go | 1 + providers/sitebuilder/tmpls.go | 5 +- 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/layouts/simplecss/templates/posts_list.html b/layouts/simplecss/templates/posts_list.html index 5f10f1e..6a2eca6 100644 --- a/layouts/simplecss/templates/posts_list.html +++ b/layouts/simplecss/templates/posts_list.html @@ -5,4 +5,10 @@ {{ template "_post_meta.html" . }}
-{{ end }} \ No newline at end of file +{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 1a4275d..601db41 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -122,7 +122,8 @@ func (b *Builder) BuildSite(outDir string) error { } func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { - var posts []postSingleData + // Collect all posts + var allPosts []postSingleData for mp := range b.site.PostIter(ctx) { post, err := mp.Get() if err != nil { @@ -132,17 +133,70 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co if err != nil { return err } - posts = append(posts, rp) + allPosts = append(allPosts, rp) } - pl := postListData{ - commonData: commonData{Site: b.site}, - Posts: posts, + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 } - return b.createAtPath(bctx, "", func(f io.Writer) error { - return b.renderTemplate(f, tmplNamePostList, pl) - }) + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 + } + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = "/posts/" + } else { + prevURL = fmt.Sprintf("/posts/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 { diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index cbe116b..a5a9bbf 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -38,6 +38,7 @@ func TestBuilder_BuildSite(t *testing.T) { } site := pubmodel.Site{ + Site: models.Site{PostsPerPage: 10}, BaseURL: "https://example.com", PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index cea02f5..2ba1c74 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -61,7 +61,10 @@ type postSingleData struct { type postListData struct { commonData - Posts []postSingleData + Posts []postSingleData + PageInfo models.PageInfo + PrevURL string + NextURL string } type layoutData struct { From f68bac809ffb500765d6f4e81f68edb05bd48e05 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:38:39 +1100 Subject: [PATCH 33/62] feat: add pagination to generated site category pages Co-Authored-By: Claude Opus 4.6 --- .../templates/categories_single.html | 8 +- providers/sitebuilder/builder.go | 75 +++++++++++++++---- providers/sitebuilder/tmpls.go | 3 + 3 files changed, 71 insertions(+), 15 deletions(-) diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html index deaeb02..e9e7116 100644 --- a/layouts/simplecss/templates/categories_single.html +++ b/layouts/simplecss/templates/categories_single.html @@ -8,4 +8,10 @@ {{ .HTML }} {{ template "_post_meta.html" . }} -{{ end }} \ No newline at end of file +{{ end }} +{{ if or .PrevURL .NextURL }} + +{{ end }} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 601db41..f18e00d 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -372,7 +372,8 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e continue } - var posts []postSingleData + // Collect all posts for this category + var allPosts []postSingleData for mp := range b.site.PostIterByCategory(goCtx, cwc.ID) { post, err := mp.Get() if err != nil { @@ -382,7 +383,7 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e if err != nil { return err } - posts = append(posts, rp) + allPosts = append(allPosts, rp) } var descHTML bytes.Buffer @@ -392,22 +393,68 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e } } - data := categorySingleData{ - commonData: commonData{Site: b.site}, - Category: &cwc.Category, - DescriptionHTML: template.HTML(descHTML.String()), - Posts: posts, - Path: fmt.Sprintf("/categories/%s", cwc.Slug), + postsPerPage := b.site.PostsPerPage + if postsPerPage < 1 { + postsPerPage = 10 } - if err := b.createAtPath(ctx, data.Path, func(f io.Writer) error { - return b.renderTemplate(f, tmplNameCategorySingle, data) - }); err != nil { - return err + totalPages := (len(allPosts) + postsPerPage - 1) / postsPerPage + if totalPages < 1 { + totalPages = 1 } - // Per-category feeds - if err := b.renderCategoryFeed(ctx, cwc, posts); err != nil { + basePath := fmt.Sprintf("/categories/%s", cwc.Slug) + + for page := 1; page <= totalPages; page++ { + start := (page - 1) * postsPerPage + end := start + postsPerPage + if end > len(allPosts) { + end = len(allPosts) + } + + pageInfo := models.PageInfo{ + CurrentPage: page, + TotalPages: totalPages, + PostsPerPage: postsPerPage, + } + + var prevURL, nextURL string + if page > 1 { + if page == 2 { + prevURL = basePath + "/" + } else { + prevURL = fmt.Sprintf("%s/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 } } diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index 2ba1c74..e0ece37 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -88,4 +88,7 @@ type categorySingleData struct { DescriptionHTML template.HTML Posts []postSingleData Path string + PageInfo models.PageInfo + PrevURL string + NextURL string } From 40da63368a37c9651497c51b3896846dcad195bb Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 14:41:50 +1100 Subject: [PATCH 34/62] fix: add nil guard for StaticFS and set default PostsPerPage in FirstRun Co-Authored-By: Claude Opus 4.6 --- providers/sitebuilder/builder.go | 3 +++ services/sites/services.go | 11 ++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index f18e00d..7523d13 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -572,6 +572,9 @@ func (b *Builder) writeUploads(ctx buildContext, uploads []models.Upload) error } func (b *Builder) writeStaticAssets(ctx buildContext) error { + if b.opts.StaticFS == nil { + return nil + } return fs.WalkDir(b.opts.StaticFS, ".", func(path string, d os.DirEntry, err error) error { if err != nil { return err diff --git a/services/sites/services.go b/services/sites/services.go index 4c974bb..86e34b2 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -77,11 +77,12 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo } newSite = models.Site{ - Title: defaultIfEmpty(req.SiteName, "New Site"), - GUID: models.NewNanoID(), - OwnerID: newUser.ID, - Timezone: "UTC", - Created: time.Now(), + Title: defaultIfEmpty(req.SiteName, "New Site"), + GUID: models.NewNanoID(), + OwnerID: newUser.ID, + Timezone: "UTC", + PostsPerPage: 10, + Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { return newUser, newSite, err From 0a1631a7e0beef2e3680605fa9dfaf28c9633372 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 16:22:32 +1100 Subject: [PATCH 35/62] Fixed paging URL --- providers/sitebuilder/builder.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 7523d13..9e5199d 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -164,11 +164,11 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co if page == 2 { prevURL = "/posts/" } else { - prevURL = fmt.Sprintf("/posts/page/%d/", page-1) + prevURL = fmt.Sprintf("/posts/%d/", page-1) } } if page < totalPages { - nextURL = fmt.Sprintf("/posts/page/%d/", page+1) + nextURL = fmt.Sprintf("/posts/%d/", page+1) } pl := postListData{ @@ -184,7 +184,7 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co if page == 1 { paths = []string{"", "/posts"} } else { - paths = []string{fmt.Sprintf("/posts/page/%d", page)} + paths = []string{fmt.Sprintf("/posts/%d", page)} } for _, path := range paths { @@ -423,16 +423,16 @@ func (b *Builder) renderCategoryPages(ctx buildContext, goCtx context.Context) e if page == 2 { prevURL = basePath + "/" } else { - prevURL = fmt.Sprintf("%s/page/%d/", basePath, page-1) + prevURL = fmt.Sprintf("%s/%d/", basePath, page-1) } } if page < totalPages { - nextURL = fmt.Sprintf("%s/page/%d/", basePath, page+1) + nextURL = fmt.Sprintf("%s/%d/", basePath, page+1) } path := basePath if page > 1 { - path = fmt.Sprintf("%s/page/%d", basePath, page) + path = fmt.Sprintf("%s/%d", basePath, page) } data := categorySingleData{ From a00567a756e191a63d6a8fc8a02de9ca73991546 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 16:37:47 +1100 Subject: [PATCH 36/62] Add arbitrary pages feature design spec Co-Authored-By: Claude Opus 4.6 --- .../specs/2026-03-22-pages-design.md | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-22-pages-design.md diff --git a/docs/superpowers/specs/2026-03-22-pages-design.md b/docs/superpowers/specs/2026-03-22-pages-design.md new file mode 100644 index 0000000..cc17417 --- /dev/null +++ b/docs/superpowers/specs/2026-03-22-pages-design.md @@ -0,0 +1,148 @@ +# Arbitrary Pages Feature Design + +## Overview + +Allow users to create arbitrary pages for their site. Each page has a title, user-editable slug, markdown body, page type, nav visibility flag, and sort order. Pages are a separate entity from posts with their own admin section and generated site template. Pages rendered at conflicting slugs silently override auto-generated content. + +## Data Layer + +### New `pages` table + +```sql +CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + body TEXT NOT NULL, + page_type INTEGER NOT NULL DEFAULT 0, + show_in_nav INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_pages_site ON pages (site_id); +CREATE UNIQUE INDEX idx_pages_guid ON pages (guid); +CREATE UNIQUE INDEX idx_pages_site_slug ON pages (site_id, slug); +``` + +### Model + +```go +type Page struct { + ID int64 + SiteID int64 + GUID string + Title string + Slug string + Body string + PageType int + ShowInNav bool + SortOrder int + CreatedAt time.Time + UpdatedAt time.Time +} +``` + +Page type constants: `PageTypeNormal = 0` (extensible later for archive, search, etc.). + +### SQL queries + +- `SelectPagesOfSite(siteID)` — all pages for a site, ordered by `sort_order ASC` +- `SelectPage(id)` — single page by ID +- `SelectPageByGUID(guid)` — single page by GUID +- `InsertPage` — create new page, returns ID +- `UpdatePage` — update page fields +- `DeletePage(id)` — delete page +- `UpdatePageSortOrder(id, sortOrder)` — update sort order for a single page + +## Admin Section + +### Navigation + +Add "Pages" item to the admin nav bar (`views/_common/nav.html`), linking to `/sites/:siteID/pages`. + +### Routes + +``` +GET /sites/:siteID/pages - List pages +GET /sites/:siteID/pages/new - New page form +GET /sites/:siteID/pages/:pageID - Edit page form +POST /sites/:siteID/pages - Create/update page +DELETE /sites/:siteID/pages/:pageID - Delete page +POST /sites/:siteID/pages/reorder - Update sort order (AJAX) +``` + +### Page list view (`views/pages/index.html`) + +- Lists pages ordered by `sort_order` +- Each row shows title, slug, and nav visibility indicator +- Drag-and-drop reordering via Stimulus + HTML drag API +- On drop, sends new order to `POST /pages/reorder` via AJAX +- "New Page" button + +### Page edit form (`views/pages/edit.html`) + +Two-column layout mirroring the post edit form: + +**Main area (left):** +- Title input +- Body textarea (markdown) + +**Sidebar (right):** +- Slug (editable text input, auto-derived from title via client-side JS, user can override) +- Page Type (select dropdown, just "Normal" for now) +- Show in Nav (checkbox) + +Save button below. + +### Service layer (`services/pages/`) + +- `Service` struct with DB provider dependency +- `CreatePage(ctx, params)` — generates GUID, derives slug from title if not provided, sets timestamps +- `UpdatePage(ctx, params)` — updates fields, sets `updated_at` +- `DeletePage(ctx, pageID)` — deletes page +- `ListPages(ctx)` — returns all pages for the site from context, ordered by `sort_order` +- `GetPage(ctx, pageID)` — returns single page +- `ReorderPages(ctx, pageIDs []int64)` — accepts ordered list of page IDs, updates `sort_order` for each (sort_order = index in list) + +### Handler (`handlers/pages.go`) + +- `PagesHandler` struct with `PageService` +- Standard CRUD handlers following the existing posts handler pattern +- `Reorder` handler accepts JSON array of page IDs, calls `ReorderPages` + +## Generated Site + +### Template + +New template `pages_single.html` — receives rendered page HTML, rendered inside `layout_main.html` (same wrapping as posts). + +Template data: +```go +type pageSingleData struct { + commonData + Page *models.Page + HTML template.HTML +} +``` + +### Builder changes + +New method `renderPages` on the builder: +- Iterates all pages from `pubmodel.Site.Pages` +- For each page, renders markdown body and writes to the page's slug path using `createAtPath` +- Pages are rendered **after** all other content (posts, post lists, categories, feeds, uploads, static assets) +- This ensures pages at conflicting slugs silently overwrite auto-generated content +- Implementation: `renderPages` runs as a sequential step after `eg.Wait()` returns in `BuildSite` + +### Publisher changes + +- `pubmodel.Site` gets a new `Pages []models.Page` field +- The publisher fetches all pages for the site via `SelectPagesOfSite` and populates this field + +## Approach + +Pages are a separate entity from posts with their own table, service, handler, and templates. The override mechanism is file-system-based: the site builder renders pages last, so any page slug that conflicts with an auto-generated path wins by overwriting the file. The `show_in_nav` field is stored and editable in admin but not yet consumed by the generated site layout — that integration is deferred for a future change. From 620ab6c6fa68c936679eb9d83f883a7dea93a613 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 17:47:19 +1100 Subject: [PATCH 37/62] docs: add pages feature implementation plan Co-Authored-By: Claude Opus 4.6 --- docs/superpowers/plans/2026-03-22-pages.md | 1218 ++++++++++++++++++++ 1 file changed, 1218 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-22-pages.md diff --git a/docs/superpowers/plans/2026-03-22-pages.md b/docs/superpowers/plans/2026-03-22-pages.md new file mode 100644 index 0000000..89a3983 --- /dev/null +++ b/docs/superpowers/plans/2026-03-22-pages.md @@ -0,0 +1,1218 @@ +# Arbitrary Pages 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:** Allow users to create arbitrary pages with title, slug, markdown body, page type, nav visibility, and sort order, rendered on the generated site. + +**Architecture:** New `pages` table + model + service + handler + admin views following the existing categories pattern. Publisher populates `pubmodel.Site.Pages`, and the site builder renders pages **after** all other content so conflicting slugs silently override auto-generated files. Drag-and-drop reordering in admin via a new Stimulus controller. + +**Tech Stack:** Go/Fiber v3, SQLite/sqlc, Bootstrap 5, Stimulus.js, goldmark markdown, html/template + +--- + +## File Structure + +**New files:** +- `sql/schema/06_pages.up.sql` - Migration for pages table +- `sql/queries/pages.sql` - sqlc queries for pages +- `models/pages.go` - Page model struct and slug helper +- `providers/db/pages.go` - DB provider methods for pages +- `services/pages/service.go` - Pages service layer +- `handlers/pages.go` - Admin pages handler +- `views/pages/index.html` - Admin page list with drag-and-drop +- `views/pages/edit.html` - Admin page edit form (two-column) +- `assets/js/controllers/pagelist.js` - Stimulus controller for drag-and-drop reorder +- `layouts/simplecss/templates/pages_single.html` - Generated site page template +- `providers/sitebuilder/render_pages.go` - Builder renderPages method + +**Modified files:** +- `providers/db/gen/sqlgen/` - Regenerated sqlc output +- `models/pubmodel/sites.go` - Add `Pages []models.Page` field +- `services/publisher/service.go` - Fetch pages and populate pubmodel +- `providers/sitebuilder/tmpls.go` - Add pageSingleData type and template constant +- `providers/sitebuilder/builder.go` - Call renderPages after eg.Wait() +- `providers/sitebuilder/builder_test.go` - Add pages to test +- `views/_common/nav.html` - Add "Pages" nav item +- `services/services.go` - Wire up pages service +- `cmds/server.go` - Wire up pages handler and routes +- `assets/js/main.js` - Register pagelist controller +- `esbuild.mjs` - No change needed (auto-picks up new JS files) + +--- + +### Task 1: Schema Migration and sqlc Queries + +**Files:** +- Create: `sql/schema/06_pages.up.sql` +- Create: `sql/queries/pages.sql` +- Regenerate: `providers/db/gen/sqlgen/` + +- [ ] **Step 1: Write the schema migration** + +```sql +-- sql/schema/06_pages.up.sql +CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + body TEXT NOT NULL, + page_type INTEGER NOT NULL DEFAULT 0, + show_in_nav INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_pages_site ON pages (site_id); +CREATE UNIQUE INDEX idx_pages_guid ON pages (guid); +CREATE UNIQUE INDEX idx_pages_site_slug ON pages (site_id, slug); +``` + +- [ ] **Step 2: Write the sqlc queries** + +```sql +-- sql/queries/pages.sql + +-- name: SelectPagesOfSite :many +SELECT * FROM pages +WHERE site_id = ? ORDER BY sort_order ASC; + +-- name: SelectPage :one +SELECT * FROM pages WHERE id = ? LIMIT 1; + +-- name: SelectPageByGUID :one +SELECT * FROM pages WHERE guid = ? LIMIT 1; + +-- name: SelectPageBySlugAndSite :one +SELECT * FROM pages WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: InsertPage :one +INSERT INTO pages ( + site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + title = ?, + slug = ?, + body = ?, + page_type = ?, + show_in_nav = ?, + updated_at = ? +WHERE id = ?; + +-- name: UpdatePageSortOrder :exec +UPDATE pages SET sort_order = ? WHERE id = ?; + +-- name: DeletePage :exec +DELETE FROM pages WHERE id = ?; +``` + +- [ ] **Step 3: Regenerate sqlc** + +Run: `sqlc generate` +Expected: Clean generation, new files in `providers/db/gen/sqlgen/` for pages queries. + +- [ ] **Step 4: Commit** + +```bash +git add sql/ providers/db/gen/ +git commit -m "feat(pages): add pages table schema and sqlc queries" +``` + +--- + +### Task 2: Page Model + +**Files:** +- Create: `models/pages.go` + +- [ ] **Step 1: Write the Page model and constants** + +```go +// models/pages.go +package models + +import ( + "strings" + "time" + "unicode" +) + +const ( + PageTypeNormal = 0 +) + +type Page struct { + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + GUID string `json:"guid"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + PageType int `json:"page_type"` + ShowInNav bool `json:"show_in_nav"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GeneratePageSlug creates a URL-safe slug from a page title. +// e.g. "About Me" -> "about-me" +func GeneratePageSlug(title string) string { + var sb strings.Builder + prevDash := false + for _, c := range strings.TrimSpace(title) { + 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 2: Write a test for GeneratePageSlug** + +```go +// models/pages_test.go +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "lmika.dev/lmika/weiro/models" +) + +func TestGeneratePageSlug(t *testing.T) { + tests := []struct { + title string + want string + }{ + {"About Me", "about-me"}, + {" Contact Us ", "contact-us"}, + {"Hello---World", "hello-world"}, + {"FAQ", "faq"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + assert.Equal(t, tt.want, models.GeneratePageSlug(tt.title)) + }) + } +} +``` + +- [ ] **Step 3: Run tests** + +Run: `go test ./models/ -run TestGeneratePageSlug -v` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add models/pages.go models/pages_test.go +git commit -m "feat(pages): add Page model and slug generator" +``` + +--- + +### Task 3: DB Provider for Pages + +**Files:** +- Create: `providers/db/pages.go` + +- [ ] **Step 1: Write the DB provider methods** + +Follow the pattern from `providers/db/categories.go`. The conversion function maps sqlgen types to model types. `ShowInNav` maps from `int64` (0/1) to `bool`. Timestamps map via `time.Unix(row.CreatedAt, 0).UTC()`. + +```go +// providers/db/pages.go +package db + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" +) + +func (db *Provider) SelectPagesOfSite(ctx context.Context, siteID int64) ([]*models.Page, error) { + rows, err := db.queries.SelectPagesOfSite(ctx, siteID) + if err != nil { + return nil, err + } + pages := make([]*models.Page, len(rows)) + for i, row := range rows { + pages[i] = dbPageToPage(row) + } + return pages, nil +} + +func (db *Provider) SelectPage(ctx context.Context, id int64) (*models.Page, error) { + row, err := db.queries.SelectPage(ctx, id) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SelectPageByGUID(ctx context.Context, guid string) (*models.Page, error) { + row, err := db.queries.SelectPageByGUID(ctx, guid) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SelectPageBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Page, error) { + row, err := db.queries.SelectPageBySlugAndSite(ctx, sqlgen.SelectPageBySlugAndSiteParams{ + SiteID: siteID, + Slug: slug, + }) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SavePage(ctx context.Context, page *models.Page) error { + if page.ID == 0 { + showInNav := int64(0) + if page.ShowInNav { + showInNav = 1 + } + newID, err := db.queries.InsertPage(ctx, sqlgen.InsertPageParams{ + SiteID: page.SiteID, + Guid: page.GUID, + Title: page.Title, + Slug: page.Slug, + Body: page.Body, + PageType: int64(page.PageType), + ShowInNav: showInNav, + SortOrder: int64(page.SortOrder), + CreatedAt: timeToInt(page.CreatedAt), + UpdatedAt: timeToInt(page.UpdatedAt), + }) + if err != nil { + return err + } + page.ID = newID + return nil + } + + showInNav := int64(0) + if page.ShowInNav { + showInNav = 1 + } + return db.queries.UpdatePage(ctx, sqlgen.UpdatePageParams{ + Title: page.Title, + Slug: page.Slug, + Body: page.Body, + PageType: int64(page.PageType), + ShowInNav: showInNav, + UpdatedAt: timeToInt(page.UpdatedAt), + ID: page.ID, + }) +} + +func (db *Provider) UpdatePageSortOrder(ctx context.Context, id int64, sortOrder int) error { + return db.queries.UpdatePageSortOrder(ctx, sqlgen.UpdatePageSortOrderParams{ + SortOrder: int64(sortOrder), + ID: id, + }) +} + +func (db *Provider) DeletePage(ctx context.Context, id int64) error { + return db.queries.DeletePage(ctx, id) +} + +func dbPageToPage(row sqlgen.Page) *models.Page { + return &models.Page{ + ID: row.ID, + SiteID: row.SiteID, + GUID: row.Guid, + Title: row.Title, + Slug: row.Slug, + Body: row.Body, + PageType: int(row.PageType), + ShowInNav: row.ShowInNav != 0, + SortOrder: int(row.SortOrder), + CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(), + } +} +``` + +**Important:** The exact field names on `sqlgen.InsertPageParams`, `sqlgen.UpdatePageParams`, etc. depend on what sqlc generates. Check the generated code in `providers/db/gen/sqlgen/pages.sql.go` to confirm field names and types before writing this file. Adjust as needed. + +- [ ] **Step 2: Verify it compiles** + +Run: `go build ./providers/db/...` +Expected: Clean compile + +- [ ] **Step 3: Commit** + +```bash +git add providers/db/pages.go +git commit -m "feat(pages): add DB provider methods for pages" +``` + +--- + +### Task 4: Pages Service + +**Files:** +- Create: `services/pages/service.go` +- Modify: `services/services.go` + +- [ ] **Step 1: Write the pages service** + +Follow the pattern from `services/categories/service.go`. The service gets site from context, validates ownership, generates slugs, and queues republish on mutations. + +```go +// services/pages/service.go +package pages + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreatePageParams struct { + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Slug string `form:"slug" json:"slug"` + Body string `form:"body" json:"body"` + PageType int `form:"page_type" json:"page_type"` + ShowInNav bool `form:"show_in_nav" json:"show_in_nav"` +} + +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) ListPages(ctx context.Context) ([]*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int64) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + return page, nil +} + +func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + // Check slug collision + if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + // Determine sort order: place at end + existingPages, err := s.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + sortOrder := len(existingPages) + + page := &models.Page{ + SiteID: site.ID, + GUID: params.GUID, + Title: params.Title, + Slug: slug, + Body: params.Body, + PageType: params.PageType, + ShowInNav: params.ShowInNav, + SortOrder: sortOrder, + CreatedAt: now, + UpdatedAt: now, + } + if page.GUID == "" { + page.GUID = models.NewNanoID() + } + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID { + return nil, models.SlugConflictError + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err + } + + page.Title = params.Title + page.Slug = slug + page.Body = params.Body + page.PageType = params.PageType + page.ShowInNav = params.ShowInNav + page.UpdatedAt = time.Now() + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) DeletePage(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeletePage(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} + +func (s *Service) ReorderPages(ctx context.Context, pageIDs []int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + // Verify all pages belong to this site + for i, id := range pageIDs { + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + if err := s.db.UpdatePageSortOrder(ctx, id, i); err != nil { + return err + } + } + + s.publisher.Queue(site) + return nil +} +``` + +- [ ] **Step 2: Make SlugConflictError generic** + +In `models/errors.go`, change: +```go +var SlugConflictError = errors.New("a category with this slug already exists") +``` +To: +```go +var SlugConflictError = errors.New("a record with this slug already exists") +``` + +- [ ] **Step 3: Wire up the service in services/services.go** + +Add to the `Services` struct: +```go +Pages *pages.Service +``` + +Add to `New()`: +```go +pagesService := pages.New(dbp, publisherQueue) +``` + +And include in the return struct: +```go +Pages: pagesService, +``` + +Add import: `"lmika.dev/lmika/weiro/services/pages"` + +- [ ] **Step 4: Verify it compiles** + +Run: `go build ./services/...` +Expected: Clean compile + +- [ ] **Step 5: Commit** + +```bash +git add services/pages/ services/services.go models/errors.go +git commit -m "feat(pages): add pages service layer" +``` + +--- + +### Task 5: Pages Handler and Routes + +**Files:** +- Create: `handlers/pages.go` +- Modify: `cmds/server.go` + +- [ ] **Step 1: Write the pages handler** + +Follow the pattern from `handlers/categories.go` for CRUD, plus a `Reorder` handler that accepts JSON. + +```go +// handlers/pages.go +package handlers + +import ( + "fmt" + "strconv" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/pages" +) + +type PagesHandler struct { + PageService *pages.Service +} + +func (ph PagesHandler) Index(c fiber.Ctx) error { + pagesList, err := ph.PageService.ListPages(c.Context()) + if err != nil { + return err + } + + return c.Render("pages/index", fiber.Map{ + "pages": pagesList, + }) +} + +func (ph PagesHandler) New(c fiber.Ctx) error { + page := models.Page{ + GUID: models.NewNanoID(), + } + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": true, + "bodyClass": "page-edit-page", + }) +} + +func (ph PagesHandler) Edit(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + page, err := ph.PageService.GetPage(c.Context(), pageID) + if err != nil { + return err + } + + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": false, + "bodyClass": "page-edit-page", + }) +} + +func (ph PagesHandler) Create(c fiber.Ctx) error { + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := ph.PageService.CreatePage(c.Context(), req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Update(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err = ph.PageService.UpdatePage(c.Context(), pageID, req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Delete(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if err := ph.PageService.DeletePage(c.Context(), pageID); err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Reorder(c fiber.Ctx) error { + var req struct { + PageIDs []int64 `json:"page_ids"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + if err := ph.PageService.ReorderPages(c.Context(), req.PageIDs); err != nil { + return err + } + + return c.JSON(fiber.Map{"ok": true}) +} +``` + +- [ ] **Step 2: Register routes in cmds/server.go** + +After the categories route block (~line 150), add: + +```go +pgh := handlers.PagesHandler{PageService: svcs.Pages} +``` + +And routes on `siteGroup`: + +```go +siteGroup.Get("/pages", pgh.Index) +siteGroup.Get("/pages/new", pgh.New) +siteGroup.Get("/pages/:pageID", pgh.Edit) +siteGroup.Post("/pages", pgh.Create) +siteGroup.Post("/pages/reorder", pgh.Reorder) +siteGroup.Post("/pages/:pageID", pgh.Update) +siteGroup.Post("/pages/:pageID/delete", pgh.Delete) +``` + +Add import: `// already imported via handlers package` + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./...` +Expected: Clean compile + +- [ ] **Step 4: Commit** + +```bash +git add handlers/pages.go cmds/server.go +git commit -m "feat(pages): add pages handler and admin routes" +``` + +--- + +### Task 6: Admin Views - Page List with Drag-and-Drop + +**Files:** +- Create: `views/pages/index.html` +- Create: `assets/js/controllers/pagelist.js` +- Modify: `assets/js/main.js` +- Modify: `views/_common/nav.html` + +- [ ] **Step 1: Add "Pages" to the admin nav bar** + +In `views/_common/nav.html`, add a new `
  • ` after the Categories nav item (after line 14): + +```html +
  • +``` + +- [ ] **Step 2: Write the page list view** + +```html + +
    +
    +
    + New Page +
    +
    + + {{ if .pages }} + + + + + + + + + + + {{ range .pages }} + + + + + + + {{ end }} + +
    TitleSlugNav
    {{ .Title }}{{ .Slug }}{{ if .ShowInNav }}Yes{{ end }}
    + {{ else }} +
    +
    No pages yet.
    +
    + {{ end }} +
    +``` + +- [ ] **Step 3: Write the pagelist Stimulus controller** + +```javascript +// assets/js/controllers/pagelist.js +import { Controller } from "@hotwired/stimulus" +import { showToast } from "../services/toast"; + +export default class PagelistController extends Controller { + static values = { + siteId: Number, + }; + + static targets = ["list"]; + + dragStart(ev) { + this.draggedRow = ev.currentTarget; + ev.currentTarget.classList.add("opacity-50"); + ev.dataTransfer.effectAllowed = "move"; + } + + dragOver(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + } + + drop(ev) { + ev.preventDefault(); + const targetRow = ev.currentTarget; + if (this.draggedRow && this.draggedRow !== targetRow) { + const rows = [...this.listTarget.children]; + const draggedIdx = rows.indexOf(this.draggedRow); + const targetIdx = rows.indexOf(targetRow); + if (draggedIdx < targetIdx) { + targetRow.after(this.draggedRow); + } else { + targetRow.before(this.draggedRow); + } + this.saveOrder(); + } + } + + dragEnd(ev) { + ev.currentTarget.classList.remove("opacity-50"); + this.draggedRow = null; + } + + async saveOrder() { + const rows = [...this.listTarget.children]; + const pageIds = rows.map(row => parseInt(row.dataset.pageId, 10)); + + try { + await fetch(`/sites/${this.siteIdValue}/pages/reorder`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ page_ids: pageIds }), + }); + } catch (error) { + showToast({ + title: "Error", + body: "Failed to reorder pages.", + }); + } + } +} +``` + +- [ ] **Step 4: Register the controller in main.js** + +Add to `assets/js/main.js`: + +```javascript +import PagelistController from "./controllers/pagelist"; +``` + +And register: + +```javascript +Stimulus.register("pagelist", PagelistController); +``` + +- [ ] **Step 5: Rebuild JS bundle** + +Run: `node esbuild.mjs` +Expected: Clean build, `static/assets/main.js` updated. + +- [ ] **Step 6: Commit** + +```bash +git add views/pages/index.html views/_common/nav.html assets/js/controllers/pagelist.js assets/js/main.js static/assets/main.js +git commit -m "feat(pages): add admin page list with drag-and-drop reorder" +``` + +--- + +### Task 7: Admin Views - Page Edit Form + +**Files:** +- Create: `views/pages/edit.html` + +- [ ] **Step 1: Write the page edit form** + +Two-column layout mirroring the post edit form: title + body on left, slug/page type/show in nav on right sidebar. + +```html + +
    + {{ if .isNew }} + + {{ else }} + + {{ end }} + +
    +
    +
    + +
    +
    + +
    +
    + + {{ if not .isNew }} + + {{ end }} +
    +
    +
    +
    +
    Page Settings
    +
    +
    + + +
    Auto-generated from title if left blank.
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    + + + {{ if not .isNew }} + + {{ end }} +
    +``` + +- [ ] **Step 2: Verify the app compiles and starts** + +Run: `go build ./...` +Expected: Clean compile. + +- [ ] **Step 3: Commit** + +```bash +git add views/pages/edit.html +git commit -m "feat(pages): add admin page edit form with sidebar" +``` + +--- + +### Task 8: Publisher and pubmodel Changes + +**Files:** +- Modify: `models/pubmodel/sites.go` +- Modify: `services/publisher/service.go` + +- [ ] **Step 1: Add Pages field to pubmodel.Site** + +In `models/pubmodel/sites.go`, add to the `Site` struct: + +```go +Pages []*models.Page +``` + +- [ ] **Step 2: Populate pages in the publisher** + +In `services/publisher/service.go`, in the `Publish` method, after fetching categories (~line 66), add: + +```go +// Fetch pages +sitePages, err := p.db.SelectPagesOfSite(ctx, site.ID) +if err != nil { + return err +} +``` + +Then in the `pubSite` construction (~line 73), add the `Pages` field: + +```go +Pages: sitePages, +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `go build ./...` +Expected: Clean compile. + +- [ ] **Step 4: Commit** + +```bash +git add models/pubmodel/sites.go services/publisher/service.go +git commit -m "feat(pages): populate pages in publisher for site generation" +``` + +--- + +### Task 9: Site Builder - Render Pages + +**Files:** +- Create: `providers/sitebuilder/render_pages.go` +- Modify: `providers/sitebuilder/tmpls.go` +- Modify: `providers/sitebuilder/builder.go` +- Create: `layouts/simplecss/templates/pages_single.html` +- Modify: `providers/sitebuilder/builder_test.go` + +- [ ] **Step 1: Add template types and constant** + +In `providers/sitebuilder/tmpls.go`, add the template name constant: + +```go +// tmplNamePageSingle is the template for a single page (pageSingleData) +tmplNamePageSingle = "pages_single.html" +``` + +And the data struct: + +```go +type pageSingleData struct { + commonData + Page *models.Page + HTML template.HTML +} +``` + +- [ ] **Step 2: Create the renderPages method** + +```go +// providers/sitebuilder/render_pages.go +package sitebuilder + +import ( + "bytes" + "context" + "html/template" + "io" + + "lmika.dev/lmika/weiro/models" +) + +func (b *Builder) renderPages(bctx buildContext) error { + for _, page := range b.site.Pages { + + var md bytes.Buffer + if err := b.mdRenderer.RenderTo(context.Background(), &md, page.Body); err != nil { + return err + } + + data := pageSingleData{ + commonData: commonData{Site: b.site}, + Page: page, + HTML: template.HTML(md.String()), + } + + path := "/" + page.Slug + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePageSingle, data) + }); err != nil { + return err + } + } + return nil +} +``` + +- [ ] **Step 3: Call renderPages after eg.Wait() in BuildSite** + +In `providers/sitebuilder/builder.go`, modify the `BuildSite` method. Replace: + +```go +return eg.Wait() +``` + +With: + +```go +if err := eg.Wait(); err != nil { + return err +} + +// Render pages last so they can override auto-generated content +return b.renderPages(buildCtx) +``` + +- [ ] **Step 4: Create the generated site template** + +```html + +{{ if .Page.Title }}

    {{ .Page.Title }}

    {{ end }} +{{ .HTML }} +``` + +- [ ] **Step 5: Add pages to the builder test** + +In `providers/sitebuilder/builder_test.go`, add `"pages_single.html"` to the `tmpls` MapFS: + +```go +"pages_single.html": {Data: []byte(`{{ if .Page.Title }}

    {{ .Page.Title }}

    {{ end }}{{ .HTML }}`)}, +``` + +Add pages to the `site` struct: + +```go +Pages: []*models.Page{ + {Title: "About", Slug: "about", Body: "About this site"}, +}, +``` + +Add to `wantFiles`: + +```go +"about/index.html": "

    About

    About this site

    \n", +``` + +- [ ] **Step 6: Run the builder test** + +Run: `go test ./providers/sitebuilder/ -v` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add providers/sitebuilder/render_pages.go providers/sitebuilder/tmpls.go providers/sitebuilder/builder.go layouts/simplecss/templates/pages_single.html providers/sitebuilder/builder_test.go +git commit -m "feat(pages): render pages in site builder after all other content" +``` + +--- + +### Task 10: Integration Test - Full Compile and Verify + +**Files:** None (verification only) + +- [ ] **Step 1: Run all tests** + +Run: `go test ./...` +Expected: All tests pass. + +- [ ] **Step 2: Verify clean build** + +Run: `go build ./...` +Expected: Clean compile, no errors. + +- [ ] **Step 3: Commit any fixes if needed** + +Only if previous steps required adjustments. From f17597e4b8ccd6283b4b7f100eca641169b6d659 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 17:57:49 +1100 Subject: [PATCH 38/62] feat(pages): add pages table schema and sqlc queries Co-Authored-By: Claude Sonnet 4.6 --- providers/db/gen/sqlgen/models.go | 14 ++ providers/db/gen/sqlgen/pages.sql.go | 219 +++++++++++++++++++++++++++ sql/queries/pages.sql | 34 +++++ sql/schema/06_pages.up.sql | 17 +++ 4 files changed, 284 insertions(+) create mode 100644 providers/db/gen/sqlgen/pages.sql.go create mode 100644 sql/queries/pages.sql create mode 100644 sql/schema/06_pages.up.sql diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index ae58594..3df1193 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -15,6 +15,20 @@ type Category struct { UpdatedAt int64 } +type Page struct { + ID int64 + SiteID int64 + Guid string + Title string + Slug string + Body string + PageType int64 + ShowInNav int64 + SortOrder int64 + CreatedAt int64 + UpdatedAt int64 +} + type PendingUpload struct { ID int64 SiteID int64 diff --git a/providers/db/gen/sqlgen/pages.sql.go b/providers/db/gen/sqlgen/pages.sql.go new file mode 100644 index 0000000..1d53291 --- /dev/null +++ b/providers/db/gen/sqlgen/pages.sql.go @@ -0,0 +1,219 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.28.0 +// source: pages.sql + +package sqlgen + +import ( + "context" +) + +const deletePage = `-- name: DeletePage :exec +DELETE FROM pages WHERE id = ? +` + +func (q *Queries) DeletePage(ctx context.Context, id int64) error { + _, err := q.db.ExecContext(ctx, deletePage, id) + return err +} + +const insertPage = `-- name: InsertPage :one +INSERT INTO pages ( + site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id +` + +type InsertPageParams struct { + SiteID int64 + Guid string + Title string + Slug string + Body string + PageType int64 + ShowInNav int64 + SortOrder int64 + CreatedAt int64 + UpdatedAt int64 +} + +func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertPage, + arg.SiteID, + arg.Guid, + arg.Title, + arg.Slug, + arg.Body, + arg.PageType, + arg.ShowInNav, + arg.SortOrder, + arg.CreatedAt, + arg.UpdatedAt, + ) + var id int64 + err := row.Scan(&id) + return id, err +} + +const selectPage = `-- name: SelectPage :one +SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages WHERE id = ? LIMIT 1 +` + +func (q *Queries) SelectPage(ctx context.Context, id int64) (Page, error) { + row := q.db.QueryRowContext(ctx, selectPage, id) + var i Page + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Title, + &i.Slug, + &i.Body, + &i.PageType, + &i.ShowInNav, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectPageByGUID = `-- name: SelectPageByGUID :one +SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages WHERE guid = ? LIMIT 1 +` + +func (q *Queries) SelectPageByGUID(ctx context.Context, guid string) (Page, error) { + row := q.db.QueryRowContext(ctx, selectPageByGUID, guid) + var i Page + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Title, + &i.Slug, + &i.Body, + &i.PageType, + &i.ShowInNav, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectPageBySlugAndSite = `-- name: SelectPageBySlugAndSite :one +SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages WHERE site_id = ? AND slug = ? LIMIT 1 +` + +type SelectPageBySlugAndSiteParams struct { + SiteID int64 + Slug string +} + +func (q *Queries) SelectPageBySlugAndSite(ctx context.Context, arg SelectPageBySlugAndSiteParams) (Page, error) { + row := q.db.QueryRowContext(ctx, selectPageBySlugAndSite, arg.SiteID, arg.Slug) + var i Page + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Title, + &i.Slug, + &i.Body, + &i.PageType, + &i.ShowInNav, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const selectPagesOfSite = `-- name: SelectPagesOfSite :many +SELECT id, site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at FROM pages +WHERE site_id = ? ORDER BY sort_order ASC +` + +func (q *Queries) SelectPagesOfSite(ctx context.Context, siteID int64) ([]Page, error) { + rows, err := q.db.QueryContext(ctx, selectPagesOfSite, siteID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Page + for rows.Next() { + var i Page + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.Guid, + &i.Title, + &i.Slug, + &i.Body, + &i.PageType, + &i.ShowInNav, + &i.SortOrder, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updatePage = `-- name: UpdatePage :exec +UPDATE pages SET + title = ?, + slug = ?, + body = ?, + page_type = ?, + show_in_nav = ?, + updated_at = ? +WHERE id = ? +` + +type UpdatePageParams struct { + Title string + Slug string + Body string + PageType int64 + ShowInNav int64 + UpdatedAt int64 + ID int64 +} + +func (q *Queries) UpdatePage(ctx context.Context, arg UpdatePageParams) error { + _, err := q.db.ExecContext(ctx, updatePage, + arg.Title, + arg.Slug, + arg.Body, + arg.PageType, + arg.ShowInNav, + arg.UpdatedAt, + arg.ID, + ) + return err +} + +const updatePageSortOrder = `-- name: UpdatePageSortOrder :exec +UPDATE pages SET sort_order = ? WHERE id = ? +` + +type UpdatePageSortOrderParams struct { + SortOrder int64 + ID int64 +} + +func (q *Queries) UpdatePageSortOrder(ctx context.Context, arg UpdatePageSortOrderParams) error { + _, err := q.db.ExecContext(ctx, updatePageSortOrder, arg.SortOrder, arg.ID) + return err +} diff --git a/sql/queries/pages.sql b/sql/queries/pages.sql new file mode 100644 index 0000000..0df22ff --- /dev/null +++ b/sql/queries/pages.sql @@ -0,0 +1,34 @@ +-- name: SelectPagesOfSite :many +SELECT * FROM pages +WHERE site_id = ? ORDER BY sort_order ASC; + +-- name: SelectPage :one +SELECT * FROM pages WHERE id = ? LIMIT 1; + +-- name: SelectPageByGUID :one +SELECT * FROM pages WHERE guid = ? LIMIT 1; + +-- name: SelectPageBySlugAndSite :one +SELECT * FROM pages WHERE site_id = ? AND slug = ? LIMIT 1; + +-- name: InsertPage :one +INSERT INTO pages ( + site_id, guid, title, slug, body, page_type, show_in_nav, sort_order, created_at, updated_at +) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +RETURNING id; + +-- name: UpdatePage :exec +UPDATE pages SET + title = ?, + slug = ?, + body = ?, + page_type = ?, + show_in_nav = ?, + updated_at = ? +WHERE id = ?; + +-- name: UpdatePageSortOrder :exec +UPDATE pages SET sort_order = ? WHERE id = ?; + +-- name: DeletePage :exec +DELETE FROM pages WHERE id = ?; diff --git a/sql/schema/06_pages.up.sql b/sql/schema/06_pages.up.sql new file mode 100644 index 0000000..5090456 --- /dev/null +++ b/sql/schema/06_pages.up.sql @@ -0,0 +1,17 @@ +CREATE TABLE pages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + site_id INTEGER NOT NULL, + guid TEXT NOT NULL, + title TEXT NOT NULL, + slug TEXT NOT NULL, + body TEXT NOT NULL, + page_type INTEGER NOT NULL DEFAULT 0, + show_in_nav INTEGER NOT NULL DEFAULT 0, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE +); +CREATE INDEX idx_pages_site ON pages (site_id); +CREATE UNIQUE INDEX idx_pages_guid ON pages (guid); +CREATE UNIQUE INDEX idx_pages_site_slug ON pages (site_id, slug); From 7755bf50431dacbff913dbf87e055f791ba4b97a Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 17:58:37 +1100 Subject: [PATCH 39/62] feat(pages): add Page model and slug generator Co-Authored-By: Claude Sonnet 4.6 --- models/pages.go | 45 ++++++++++++++++++++++++++++++++++++++++++++ models/pages_test.go | 26 +++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 models/pages.go create mode 100644 models/pages_test.go diff --git a/models/pages.go b/models/pages.go new file mode 100644 index 0000000..1022120 --- /dev/null +++ b/models/pages.go @@ -0,0 +1,45 @@ +package models + +import ( + "strings" + "time" + "unicode" +) + +const ( + PageTypeNormal = 0 +) + +type Page struct { + ID int64 `json:"id"` + SiteID int64 `json:"site_id"` + GUID string `json:"guid"` + Title string `json:"title"` + Slug string `json:"slug"` + Body string `json:"body"` + PageType int `json:"page_type"` + ShowInNav bool `json:"show_in_nav"` + SortOrder int `json:"sort_order"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// GeneratePageSlug creates a URL-safe slug from a page title. +// e.g. "About Me" -> "about-me" +func GeneratePageSlug(title string) string { + var sb strings.Builder + prevDash := false + for _, c := range strings.TrimSpace(title) { + 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, "-") +} diff --git a/models/pages_test.go b/models/pages_test.go new file mode 100644 index 0000000..831b31f --- /dev/null +++ b/models/pages_test.go @@ -0,0 +1,26 @@ +package models_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "lmika.dev/lmika/weiro/models" +) + +func TestGeneratePageSlug(t *testing.T) { + tests := []struct { + title string + want string + }{ + {"About Me", "about-me"}, + {" Contact Us ", "contact-us"}, + {"Hello---World", "hello-world"}, + {"FAQ", "faq"}, + {"", ""}, + } + for _, tt := range tests { + t.Run(tt.title, func(t *testing.T) { + assert.Equal(t, tt.want, models.GeneratePageSlug(tt.title)) + }) + } +} From 2cd9ff87211455fc0014d8bfa3a9f0e15dd0474e Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 17:59:43 +1100 Subject: [PATCH 40/62] feat(pages): add DB provider methods for pages Co-Authored-By: Claude Sonnet 4.6 --- providers/db/pages.go | 115 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 providers/db/pages.go diff --git a/providers/db/pages.go b/providers/db/pages.go new file mode 100644 index 0000000..1e5b9fc --- /dev/null +++ b/providers/db/pages.go @@ -0,0 +1,115 @@ +package db + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db/gen/sqlgen" +) + +func (db *Provider) SelectPagesOfSite(ctx context.Context, siteID int64) ([]*models.Page, error) { + rows, err := db.queries.SelectPagesOfSite(ctx, siteID) + if err != nil { + return nil, err + } + pages := make([]*models.Page, len(rows)) + for i, row := range rows { + pages[i] = dbPageToPage(row) + } + return pages, nil +} + +func (db *Provider) SelectPage(ctx context.Context, id int64) (*models.Page, error) { + row, err := db.queries.SelectPage(ctx, id) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SelectPageByGUID(ctx context.Context, guid string) (*models.Page, error) { + row, err := db.queries.SelectPageByGUID(ctx, guid) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SelectPageBySlugAndSite(ctx context.Context, siteID int64, slug string) (*models.Page, error) { + row, err := db.queries.SelectPageBySlugAndSite(ctx, sqlgen.SelectPageBySlugAndSiteParams{ + SiteID: siteID, + Slug: slug, + }) + if err != nil { + return nil, err + } + return dbPageToPage(row), nil +} + +func (db *Provider) SavePage(ctx context.Context, page *models.Page) error { + if page.ID == 0 { + showInNav := int64(0) + if page.ShowInNav { + showInNav = 1 + } + newID, err := db.queries.InsertPage(ctx, sqlgen.InsertPageParams{ + SiteID: page.SiteID, + Guid: page.GUID, + Title: page.Title, + Slug: page.Slug, + Body: page.Body, + PageType: int64(page.PageType), + ShowInNav: showInNav, + SortOrder: int64(page.SortOrder), + CreatedAt: timeToInt(page.CreatedAt), + UpdatedAt: timeToInt(page.UpdatedAt), + }) + if err != nil { + return err + } + page.ID = newID + return nil + } + + showInNav := int64(0) + if page.ShowInNav { + showInNav = 1 + } + return db.queries.UpdatePage(ctx, sqlgen.UpdatePageParams{ + Title: page.Title, + Slug: page.Slug, + Body: page.Body, + PageType: int64(page.PageType), + ShowInNav: showInNav, + UpdatedAt: timeToInt(page.UpdatedAt), + ID: page.ID, + }) +} + +func (db *Provider) UpdatePageSortOrder(ctx context.Context, id int64, sortOrder int) error { + return db.queries.UpdatePageSortOrder(ctx, sqlgen.UpdatePageSortOrderParams{ + SortOrder: int64(sortOrder), + ID: id, + }) +} + +func (db *Provider) DeletePage(ctx context.Context, id int64) error { + return db.queries.DeletePage(ctx, id) +} + +func dbPageToPage(row sqlgen.Page) *models.Page { + return &models.Page{ + ID: row.ID, + SiteID: row.SiteID, + GUID: row.Guid, + Title: row.Title, + Slug: row.Slug, + Body: row.Body, + PageType: int(row.PageType), + ShowInNav: row.ShowInNav != 0, + SortOrder: int(row.SortOrder), + CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), + UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(), + } +} From 1edcd7686cf9ee9cc5c317c97d05d91912949b43 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 18:01:36 +1100 Subject: [PATCH 41/62] feat(pages): add pages service layer Implements the pages service with ListPages, GetPage, CreatePage, UpdatePage, DeletePage, and ReorderPages methods. Wires the service into the service registry and generalises SlugConflictError message. Co-Authored-By: Claude Sonnet 4.6 --- models/errors.go | 2 +- services/pages/service.go | 189 ++++++++++++++++++++++++++++++++++++++ services/services.go | 4 + 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 services/pages/service.go diff --git a/models/errors.go b/models/errors.go index eda780c..3efadbc 100644 --- a/models/errors.go +++ b/models/errors.go @@ -7,4 +7,4 @@ var PermissionError = errors.New("permission denied") var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") -var SlugConflictError = errors.New("a category with this slug already exists") +var SlugConflictError = errors.New("a record with this slug already exists") diff --git a/services/pages/service.go b/services/pages/service.go new file mode 100644 index 0000000..37c4144 --- /dev/null +++ b/services/pages/service.go @@ -0,0 +1,189 @@ +package pages + +import ( + "context" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/services/publisher" +) + +type CreatePageParams struct { + GUID string `form:"guid" json:"guid"` + Title string `form:"title" json:"title"` + Slug string `form:"slug" json:"slug"` + Body string `form:"body" json:"body"` + PageType int `form:"page_type" json:"page_type"` + ShowInNav bool `form:"show_in_nav" json:"show_in_nav"` +} + +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) ListPages(ctx context.Context) ([]*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + return s.db.SelectPagesOfSite(ctx, site.ID) +} + +func (s *Service) GetPage(ctx context.Context, id int64) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + return page, nil +} + +func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + now := time.Now() + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + // Check slug collision + if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil { + return nil, models.SlugConflictError + } else if !db.ErrorIsNoRows(err) { + return nil, err + } + + // Determine sort order: place at end + existingPages, err := s.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return nil, err + } + sortOrder := len(existingPages) + + page := &models.Page{ + SiteID: site.ID, + GUID: params.GUID, + Title: params.Title, + Slug: slug, + Body: params.Body, + PageType: params.PageType, + ShowInNav: params.ShowInNav, + SortOrder: sortOrder, + CreatedAt: now, + UpdatedAt: now, + } + if page.GUID == "" { + page.GUID = models.NewNanoID() + } + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePageParams) (*models.Page, error) { + site, ok := models.GetSite(ctx) + if !ok { + return nil, models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return nil, err + } + if page.SiteID != site.ID { + return nil, models.NotFoundError + } + + slug := params.Slug + if slug == "" { + slug = models.GeneratePageSlug(params.Title) + } + + // Check slug collision (exclude self) + if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID { + return nil, models.SlugConflictError + } else if err != nil && !db.ErrorIsNoRows(err) { + return nil, err + } + + page.Title = params.Title + page.Slug = slug + page.Body = params.Body + page.PageType = params.PageType + page.ShowInNav = params.ShowInNav + page.UpdatedAt = time.Now() + + if err := s.db.SavePage(ctx, page); err != nil { + return nil, err + } + + s.publisher.Queue(site) + return page, nil +} + +func (s *Service) DeletePage(ctx context.Context, id int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + + if err := s.db.DeletePage(ctx, id); err != nil { + return err + } + + s.publisher.Queue(site) + return nil +} + +func (s *Service) ReorderPages(ctx context.Context, pageIDs []int64) error { + site, ok := models.GetSite(ctx) + if !ok { + return models.SiteRequiredError + } + + // Verify all pages belong to this site + for i, id := range pageIDs { + page, err := s.db.SelectPage(ctx, id) + if err != nil { + return err + } + if page.SiteID != site.ID { + return models.NotFoundError + } + if err := s.db.UpdatePageSortOrder(ctx, id, i); err != nil { + return err + } + } + + s.publisher.Queue(site) + return nil +} diff --git a/services/services.go b/services/services.go index beb6727..852dea3 100644 --- a/services/services.go +++ b/services/services.go @@ -8,6 +8,7 @@ import ( "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/categories" + "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/sites" @@ -23,6 +24,7 @@ type Services struct { Sites *sites.Service Uploads *uploads.Service Categories *categories.Service + Pages *pages.Service } func New(cfg config.Config) (*Services, error) { @@ -40,6 +42,7 @@ func New(cfg config.Config) (*Services, error) { siteService := sites.New(dbp) uploadService := uploads.New(dbp, ufp, filepath.Join(cfg.ScratchDir, "uploads", "pending")) categoriesService := categories.New(dbp, publisherQueue) + pagesService := pages.New(dbp, publisherQueue) return &Services{ DB: dbp, @@ -50,6 +53,7 @@ func New(cfg config.Config) (*Services, error) { Sites: siteService, Uploads: uploadService, Categories: categoriesService, + Pages: pagesService, }, nil } From f386403ced0adb44aa8d3ee4140660617ed78ffb Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 18:03:06 +1100 Subject: [PATCH 42/62] feat(pages): add pages handler and admin routes Co-Authored-By: Claude Sonnet 4.6 --- cmds/server.go | 9 ++++ handlers/pages.go | 118 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 handlers/pages.go diff --git a/cmds/server.go b/cmds/server.go index 56517e7..89310bd 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -113,6 +113,7 @@ Starting weiro without any arguments will start the server. uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} + pgh := handlers.PagesHandler{PageService: svcs.Pages} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -149,6 +150,14 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/categories/:categoryID", ch.Update) siteGroup.Post("/categories/:categoryID/delete", ch.Delete) + siteGroup.Get("/pages", pgh.Index) + siteGroup.Get("/pages/new", pgh.New) + siteGroup.Get("/pages/:pageID", pgh.Edit) + siteGroup.Post("/pages", pgh.Create) + siteGroup.Post("/pages/reorder", pgh.Reorder) + siteGroup.Post("/pages/:pageID", pgh.Update) + siteGroup.Post("/pages/:pageID/delete", pgh.Delete) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) app.Get("/first-run", ih.FirstRun) app.Post("/first-run", ih.FirstRunSubmit) diff --git a/handlers/pages.go b/handlers/pages.go new file mode 100644 index 0000000..2bdb04f --- /dev/null +++ b/handlers/pages.go @@ -0,0 +1,118 @@ +package handlers + +import ( + "fmt" + "strconv" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/pages" +) + +type PagesHandler struct { + PageService *pages.Service +} + +func (ph PagesHandler) Index(c fiber.Ctx) error { + pagesList, err := ph.PageService.ListPages(c.Context()) + if err != nil { + return err + } + + return c.Render("pages/index", fiber.Map{ + "pages": pagesList, + }) +} + +func (ph PagesHandler) New(c fiber.Ctx) error { + page := models.Page{ + GUID: models.NewNanoID(), + } + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": true, + "bodyClass": "page-edit-page", + }) +} + +func (ph PagesHandler) Edit(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + page, err := ph.PageService.GetPage(c.Context(), pageID) + if err != nil { + return err + } + + return c.Render("pages/edit", fiber.Map{ + "page": page, + "isNew": false, + "bodyClass": "page-edit-page", + }) +} + +func (ph PagesHandler) Create(c fiber.Ctx) error { + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err := ph.PageService.CreatePage(c.Context(), req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Update(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + var req pages.CreatePageParams + if err := c.Bind().Body(&req); err != nil { + return err + } + + _, err = ph.PageService.UpdatePage(c.Context(), pageID, req) + if err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Delete(c fiber.Ctx) error { + pageID, err := strconv.ParseInt(c.Params("pageID"), 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + if err := ph.PageService.DeletePage(c.Context(), pageID); err != nil { + return err + } + + site := models.MustGetSite(c.Context()) + return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID)) +} + +func (ph PagesHandler) Reorder(c fiber.Ctx) error { + var req struct { + PageIDs []int64 `json:"page_ids"` + } + if err := c.Bind().Body(&req); err != nil { + return err + } + + if err := ph.PageService.ReorderPages(c.Context(), req.PageIDs); err != nil { + return err + } + + return c.JSON(fiber.Map{"ok": true}) +} From 5eece96700a41bc267305d1f5dfa3d6176902b43 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 19:06:48 +1100 Subject: [PATCH 43/62] feat(pages): add admin page list with drag-and-drop reorder Co-Authored-By: Claude Sonnet 4.6 --- assets/js/controllers/pagelist.js | 63 +++++++++++++++++++++++++++++++ assets/js/main.js | 4 +- views/_common/nav.html | 3 ++ views/pages/index.html | 35 +++++++++++++++++ 4 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 assets/js/controllers/pagelist.js create mode 100644 views/pages/index.html diff --git a/assets/js/controllers/pagelist.js b/assets/js/controllers/pagelist.js new file mode 100644 index 0000000..7da6872 --- /dev/null +++ b/assets/js/controllers/pagelist.js @@ -0,0 +1,63 @@ +import { Controller } from "@hotwired/stimulus" +import { showToast } from "../services/toast"; + +export default class PagelistController extends Controller { + static values = { + siteId: Number, + }; + + static targets = ["list"]; + + dragStart(ev) { + this.draggedRow = ev.currentTarget; + ev.currentTarget.classList.add("opacity-50"); + ev.dataTransfer.effectAllowed = "move"; + } + + dragOver(ev) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + } + + drop(ev) { + ev.preventDefault(); + const targetRow = ev.currentTarget; + if (this.draggedRow && this.draggedRow !== targetRow) { + const rows = [...this.listTarget.children]; + const draggedIdx = rows.indexOf(this.draggedRow); + const targetIdx = rows.indexOf(targetRow); + if (draggedIdx < targetIdx) { + targetRow.after(this.draggedRow); + } else { + targetRow.before(this.draggedRow); + } + this.saveOrder(); + } + } + + dragEnd(ev) { + ev.currentTarget.classList.remove("opacity-50"); + this.draggedRow = null; + } + + async saveOrder() { + const rows = [...this.listTarget.children]; + const pageIds = rows.map(row => parseInt(row.dataset.pageId, 10)); + + try { + await fetch(`/sites/${this.siteIdValue}/pages/reorder`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + body: JSON.stringify({ page_ids: pageIds }), + }); + } catch (error) { + showToast({ + title: "Error", + body: "Failed to reorder pages.", + }); + } + } +} diff --git a/assets/js/main.js b/assets/js/main.js index d76c353..28451fb 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -7,6 +7,7 @@ import LogoutController from "./controllers/logout"; import FirstRunController from "./controllers/firstrun"; import UploadController from "./controllers/upload"; import ShowUploadController from "./controllers/show_upload"; +import PagelistController from "./controllers/pagelist"; window.Stimulus = Application.start() Stimulus.register("toast", ToastController); @@ -15,4 +16,5 @@ Stimulus.register("postedit", PosteditController); Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); Stimulus.register("upload", UploadController); -Stimulus.register("show-upload", ShowUploadController); \ No newline at end of file +Stimulus.register("show-upload", ShowUploadController); +Stimulus.register("pagelist", PagelistController); \ No newline at end of file diff --git a/views/_common/nav.html b/views/_common/nav.html index e8bce30..ed7a1a9 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -13,6 +13,9 @@ + diff --git a/views/pages/index.html b/views/pages/index.html new file mode 100644 index 0000000..3011c64 --- /dev/null +++ b/views/pages/index.html @@ -0,0 +1,35 @@ +
    +
    +
    + New Page +
    +
    + + {{ if .pages }} + + + + + + + + + + + {{ range .pages }} + + + + + + + {{ end }} + +
    TitleSlugNav
    {{ .Title }}{{ .Slug }}{{ if .ShowInNav }}Yes{{ end }}
    + {{ else }} +
    +
    No pages yet.
    +
    + {{ end }} +
    From 255fa26a15c6c756648765298d68fed4f771cfe7 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 19:07:49 +1100 Subject: [PATCH 44/62] feat(pages): add admin page edit form with sidebar Co-Authored-By: Claude Sonnet 4.6 --- views/pages/edit.html | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 views/pages/edit.html diff --git a/views/pages/edit.html b/views/pages/edit.html new file mode 100644 index 0000000..1c4b8c0 --- /dev/null +++ b/views/pages/edit.html @@ -0,0 +1,53 @@ +
    + {{ if .isNew }} +
    + {{ else }} + + {{ end }} + +
    +
    +
    + +
    +
    + +
    +
    + + {{ if not .isNew }} + + {{ end }} +
    +
    +
    +
    +
    Page Settings
    +
    +
    + + +
    Auto-generated from title if left blank.
    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +
    +
    + + {{ if not .isNew }} + + {{ end }} +
    From d464821a8c0c5a63ce1aa365ad850082601923ba Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 19:09:01 +1100 Subject: [PATCH 45/62] feat(pages): populate pages in publisher for site generation --- models/pubmodel/sites.go | 1 + services/publisher/service.go | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index a8862c4..38ba614 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -18,4 +18,5 @@ type Site struct { 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) + Pages []*models.Page } diff --git a/services/publisher/service.go b/services/publisher/service.go index 939817a..e6ccde3 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -65,6 +65,12 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { }) } + // Fetch pages + sitePages, err := p.db.SelectPagesOfSite(ctx, site.ID) + if err != nil { + return err + } + for _, target := range targets { if !target.Enabled { continue @@ -84,6 +90,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { CategoriesOfPost: func(ctx context.Context, postID int64) ([]*models.Category, error) { return p.db.SelectCategoriesOfPost(ctx, postID) }, + Pages: sitePages, OpenUpload: func(u models.Upload) (io.ReadCloser, error) { return p.up.OpenUpload(site, u) }, From ef038172ac7704851dbe6641eed191f973444258 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 22 Mar 2026 19:11:12 +1100 Subject: [PATCH 46/62] feat(pages): render pages in site builder after all other content Co-Authored-By: Claude Sonnet 4.6 --- layouts/simplecss/templates/pages_single.html | 2 ++ providers/sitebuilder/builder.go | 7 ++++- providers/sitebuilder/builder_test.go | 5 +++ providers/sitebuilder/render_pages.go | 31 +++++++++++++++++++ providers/sitebuilder/tmpls.go | 9 ++++++ 5 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 layouts/simplecss/templates/pages_single.html create mode 100644 providers/sitebuilder/render_pages.go diff --git a/layouts/simplecss/templates/pages_single.html b/layouts/simplecss/templates/pages_single.html new file mode 100644 index 0000000..6883c3e --- /dev/null +++ b/layouts/simplecss/templates/pages_single.html @@ -0,0 +1,2 @@ +{{ if .Page.Title }}

    {{ .Page.Title }}

    {{ end }} +{{ .HTML }} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 9e5199d..93d787e 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -118,7 +118,12 @@ func (b *Builder) BuildSite(outDir string) error { // Build static assets eg.Go(func() error { return b.writeStaticAssets(buildCtx) }) - return eg.Wait() + if err := eg.Wait(); err != nil { + return err + } + + // Render pages last so they can override auto-generated content + return b.renderPages(buildCtx) } func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Context) error { diff --git a/providers/sitebuilder/builder_test.go b/providers/sitebuilder/builder_test.go index a5a9bbf..3fec74f 100644 --- a/providers/sitebuilder/builder_test.go +++ b/providers/sitebuilder/builder_test.go @@ -22,6 +22,7 @@ func TestBuilder_BuildSite(t *testing.T) { "layout_main.html": {Data: []byte(`{{ .Body }}`)}, "categories_list.html": {Data: []byte(`{{ range .Categories}}{{.Name}},{{ end }}`)}, "categories_single.html": {Data: []byte(`

    {{.Category.Name}}

    `)}, + "pages_single.html": {Data: []byte(`{{ if .Page.Title }}

    {{ .Page.Title }}

    {{ end }}{{ .HTML }}`)}, } posts := []*models.Post{ @@ -49,11 +50,15 @@ func TestBuilder_BuildSite(t *testing.T) { } } }, + Pages: []*models.Page{ + {Title: "About", Slug: "about", Body: "About this site"}, + }, } wantFiles := map[string]string{ "2026/02/18/test-post/index.html": "

    This is a test post

    \n", "2026/02/20/another-post/index.html": "

    This is another test post

    \n", "index.html": "Test Post,Another Post,", + "about/index.html": "

    About

    About this site

    \n", } outDir := t.TempDir() diff --git a/providers/sitebuilder/render_pages.go b/providers/sitebuilder/render_pages.go new file mode 100644 index 0000000..6183088 --- /dev/null +++ b/providers/sitebuilder/render_pages.go @@ -0,0 +1,31 @@ +package sitebuilder + +import ( + "bytes" + "context" + "html/template" + "io" +) + +func (b *Builder) renderPages(bctx buildContext) error { + for _, page := range b.site.Pages { + var md bytes.Buffer + if err := b.mdRenderer.RenderTo(context.Background(), &md, page.Body); err != nil { + return err + } + + data := pageSingleData{ + commonData: commonData{Site: b.site}, + Page: page, + HTML: template.HTML(md.String()), + } + + path := "/" + page.Slug + if err := b.createAtPath(bctx, path, func(f io.Writer) error { + return b.renderTemplate(f, tmplNamePageSingle, data) + }); err != nil { + return err + } + } + return nil +} diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index e0ece37..a0c8e34 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -26,6 +26,9 @@ const ( // tmplNameCategorySingle is the template for a single category page tmplNameCategorySingle = "categories_single.html" + + // tmplNamePageSingle is the template for a single page (pageSingleData) + tmplNamePageSingle = "pages_single.html" ) type Options struct { @@ -92,3 +95,9 @@ type categorySingleData struct { PrevURL string NextURL string } + +type pageSingleData struct { + commonData + Page *models.Page + HTML template.HTML +} From 5badce0d1620ced942a24217fdef4c5f9321278b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 23 Mar 2026 21:48:43 +1100 Subject: [PATCH 47/62] Updated some settings in pages --- handlers/pages.go | 4 +-- .../templates/categories_single.html | 4 +-- layouts/simplecss/templates/posts_list.html | 4 +-- providers/sitebuilder/builder.go | 12 +++---- providers/sitebuilder/tmpls.go | 7 ++-- services/pages/service.go | 9 +++++ services/publisher/service.go | 15 ++++---- views/_common/nav.html | 6 ++-- views/pages/edit.html | 34 ++++++++++--------- views/posts/edit.html | 8 ++--- 10 files changed, 56 insertions(+), 47 deletions(-) diff --git a/handlers/pages.go b/handlers/pages.go index 2bdb04f..abefb41 100644 --- a/handlers/pages.go +++ b/handlers/pages.go @@ -31,7 +31,7 @@ func (ph PagesHandler) New(c fiber.Ctx) error { return c.Render("pages/edit", fiber.Map{ "page": page, "isNew": true, - "bodyClass": "page-edit-page", + "bodyClass": "post-edit-page", }) } @@ -49,7 +49,7 @@ func (ph PagesHandler) Edit(c fiber.Ctx) error { return c.Render("pages/edit", fiber.Map{ "page": page, "isNew": false, - "bodyClass": "page-edit-page", + "bodyClass": "post-edit-page", }) } diff --git a/layouts/simplecss/templates/categories_single.html b/layouts/simplecss/templates/categories_single.html index e9e7116..133ad8d 100644 --- a/layouts/simplecss/templates/categories_single.html +++ b/layouts/simplecss/templates/categories_single.html @@ -11,7 +11,7 @@ {{ end }} {{ if or .PrevURL .NextURL }} {{ end }} diff --git a/layouts/simplecss/templates/posts_list.html b/layouts/simplecss/templates/posts_list.html index 6a2eca6..6a71533 100644 --- a/layouts/simplecss/templates/posts_list.html +++ b/layouts/simplecss/templates/posts_list.html @@ -8,7 +8,7 @@ {{ end }} {{ if or .PrevURL .NextURL }} {{ end }} diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 93d787e..71ce926 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -166,14 +166,10 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co var prevURL, nextURL string if page > 1 { - if page == 2 { - prevURL = "/posts/" - } else { - prevURL = fmt.Sprintf("/posts/%d/", page-1) - } + prevURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page-1) } if page < totalPages { - nextURL = fmt.Sprintf("/posts/%d/", page+1) + nextURL = fmt.Sprintf("%v/%d", b.opts.BasePostList, page+1) } pl := postListData{ @@ -187,9 +183,9 @@ func (b *Builder) renderPostListWithCategories(bctx buildContext, ctx context.Co // Page 1 renders at both root and /posts/ var paths []string if page == 1 { - paths = []string{"", "/posts"} + paths = []string{"", fmt.Sprintf("%v/1", b.opts.BasePostList)} } else { - paths = []string{fmt.Sprintf("/posts/%d", page)} + paths = []string{fmt.Sprintf("%v/%d", b.opts.BasePostList, page)} } for _, path := range paths { diff --git a/providers/sitebuilder/tmpls.go b/providers/sitebuilder/tmpls.go index a0c8e34..029cab0 100644 --- a/providers/sitebuilder/tmpls.go +++ b/providers/sitebuilder/tmpls.go @@ -32,9 +32,10 @@ const ( ) type Options struct { - BasePosts string // BasePosts is the base path for posts. - BaseUploads string // BaseUploads is the base path for uploads. - BaseStatic string // BaseStatic is the base path for static assets. + BasePosts string // BasePosts is the base path for posts. + BasePostList string // BasePostList is the base path for post lists. + BaseUploads string // BaseUploads is the base path for uploads. + BaseStatic string // BaseStatic is the base path for static assets. // TemplatesFS provides the raw templates for rendering the site. TemplatesFS fs.FS diff --git a/services/pages/service.go b/services/pages/service.go index 37c4144..8a82bc0 100644 --- a/services/pages/service.go +++ b/services/pages/service.go @@ -2,6 +2,7 @@ package pages import ( "context" + "strings" "time" "lmika.dev/lmika/weiro/models" @@ -63,6 +64,10 @@ func (s *Service) CreatePage(ctx context.Context, params CreatePageParams) (*mod slug = models.GeneratePageSlug(params.Title) } + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + // Check slug collision if _, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil { return nil, models.SlugConflictError @@ -120,6 +125,10 @@ func (s *Service) UpdatePage(ctx context.Context, id int64, params CreatePagePar slug = models.GeneratePageSlug(params.Title) } + if !strings.HasPrefix(slug, "/") { + slug = "/" + slug + } + // Check slug collision (exclude self) if existing, err := s.db.SelectPageBySlugAndSite(ctx, site.ID, slug); err == nil && existing.ID != page.ID { return nil, models.SlugConflictError diff --git a/services/publisher/service.go b/services/publisher/service.go index e6ccde3..adfcdd7 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -121,13 +121,14 @@ func (p *Publisher) publishSite(ctx context.Context, pubSite pubmodel.Site, targ } sb, err := sitebuilder.New(pubSite, sitebuilder.Options{ - BasePosts: "/posts", - BaseUploads: "/uploads", - BaseStatic: "/static", - TemplatesFS: templateFS, - StaticFS: staticFS, - FeedItems: 30, - RenderTZ: renderTZ, + BasePosts: "/posts", + BasePostList: "/pages", + BaseUploads: "/uploads", + BaseStatic: "/static", + TemplatesFS: templateFS, + StaticFS: staticFS, + FeedItems: 30, + RenderTZ: renderTZ, }) if err != nil { return err diff --git a/views/_common/nav.html b/views/_common/nav.html index ed7a1a9..e9c0de7 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -10,15 +10,15 @@ - + diff --git a/views/pages/edit.html b/views/pages/edit.html index 1c4b8c0..d534b80 100644 --- a/views/pages/edit.html +++ b/views/pages/edit.html @@ -1,8 +1,8 @@ -
    +
    {{ if .isNew }} -
    + {{ else }} - + {{ end }}
    @@ -10,10 +10,8 @@
    -
    - -
    -
    + +
    {{ if not .isNew }}
    -
    +
    {{ if $isPublished }} - + {{ else }} - - + + {{ end }}
    From d80aacc180a225da45cd54f02ecdaf4b7d85d128 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Tue, 24 Mar 2026 11:08:51 +1100 Subject: [PATCH 48/62] Added a site picker plus options to create new sites --- assets/css/main.scss | 9 +++++ assets/js/main.js | 5 ++- cmds/server.go | 18 ++++++---- handlers/middleware/errlog.go | 2 +- handlers/middleware/site.go | 12 ++++++- handlers/sitesettings.go | 24 +++++++++++-- package-lock.json | 36 ++++++++++++++++--- package.json | 3 +- services/sites/services.go | 66 ++++++++++++++++++++++++++++++----- views/_common/nav.html | 20 ++++++++++- views/index/first-run.html | 2 +- views/pages/index.html | 2 +- views/sitesettings/new.html | 29 +++++++++++++++ views/uploads/index.html | 4 +++ 14 files changed, 203 insertions(+), 29 deletions(-) create mode 100644 views/sitesettings/new.html diff --git a/assets/css/main.scss b/assets/css/main.scss index dc6ad7d..2e0883a 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -10,6 +10,15 @@ $container-max-widths: ( @import "bootstrap/scss/bootstrap.scss"; +// Navbar + +.navbar-site-visit { + display: inline-block; + line-height: 2em; + margin-bottom: 4px; + margin-right: 10px; +} + // Post list .postlist .post img { diff --git a/assets/js/main.js b/assets/js/main.js index 28451fb..fcbe286 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -1,3 +1,4 @@ +import feather from "feather-icons/dist/feather.js"; import { Application } from "@hotwired/stimulus"; import ToastController from "./controllers/toast"; @@ -17,4 +18,6 @@ Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); Stimulus.register("upload", UploadController); Stimulus.register("show-upload", ShowUploadController); -Stimulus.register("pagelist", PagelistController); \ No newline at end of file +Stimulus.register("pagelist", PagelistController); + +feather.replace(); \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 89310bd..36e5923 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -119,7 +119,17 @@ Starting weiro without any arguments will start the server. app.Post("/login", lh.DoLogin) app.Post("/logout", lh.Logout) - siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites)) + app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) + app.Get("/first-run", ih.FirstRun) + app.Post("/first-run", ih.FirstRunSubmit) + + app.Get("/static/*", static.New("./static")) + + app.Use(middleware.LogErrors(), middleware.RequireUser(svcs.Auth)) + + app.Get("/sites/new", ssh.New) + app.Post("/sites", ssh.Create) + siteGroup := app.Group("/sites/:siteID", middleware.RequiresSite(svcs.Sites)) siteGroup.Get("/posts", ph.Index) siteGroup.Get("/posts/new", ph.New) @@ -158,12 +168,6 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/pages/:pageID", pgh.Update) siteGroup.Post("/pages/:pageID/delete", pgh.Delete) - app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index) - app.Get("/first-run", ih.FirstRun) - app.Post("/first-run", ih.FirstRunSubmit) - - app.Get("/static/*", static.New("./static")) - if err := app.Listen(":3000"); err != nil { log.Println(err) } diff --git a/handlers/middleware/errlog.go b/handlers/middleware/errlog.go index 5b6dfa6..2acac04 100644 --- a/handlers/middleware/errlog.go +++ b/handlers/middleware/errlog.go @@ -9,7 +9,7 @@ import ( func LogErrors() func(c fiber.Ctx) error { return func(c fiber.Ctx) error { if err := c.Next(); err != nil { - log.Printf("error: %v\n", err) + log.Printf("%v: error: %v\n", c.Path(), err) return err } return nil diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go index 54211bc..6f47430 100644 --- a/handlers/middleware/site.go +++ b/handlers/middleware/site.go @@ -32,9 +32,19 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error { return err } } - c.Locals("site", site) c.SetContext(models.WithSite(c.Context(), site)) + + sitesOwnedByUser, err := sites.ListSites(c.Context()) + if err != nil { + return err + } + c.Locals("allSites", sitesOwnedByUser) + + if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil { + c.Locals("pubTarget", pubTargets) + } + return c.Next() } } diff --git a/handlers/sitesettings.go b/handlers/sitesettings.go index 0fe2100..e61ced4 100644 --- a/handlers/sitesettings.go +++ b/handlers/sitesettings.go @@ -12,10 +12,28 @@ type SiteSettingsHandler struct { SiteService *sites.Service } -func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error { - site := ctx.Locals("site").(models.Site) +func (s *SiteSettingsHandler) New(c fiber.Ctx) error { + return c.Render("sitesettings/new", fiber.Map{}, "layouts/bare_with_scripts") +} - return ctx.Render("sitesettings/general", fiber.Map{ +func (s *SiteSettingsHandler) Create(c fiber.Ctx) error { + var params sites.CreateSiteParams + if err := c.Bind().Body(¶ms); err != nil { + return err + } + + newSite, err := s.SiteService.CreateSite(c.Context(), params) + if err != nil { + return err + } + + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", newSite.ID)) +} + +func (s *SiteSettingsHandler) General(c fiber.Ctx) error { + site := c.Locals("site").(models.Site) + + return c.Render("sitesettings/general", fiber.Map{ "site": site, "tzones": sites.ListZones(), }) diff --git a/package-lock.json b/package-lock.json index c4f391c..2068fd3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,8 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", - "esbuild-sass-plugin": "^3.6.0" + "esbuild-sass-plugin": "^3.6.0", + "feather-icons": "^4.29.2" }, "devDependencies": { "esbuild": "0.27.3" @@ -783,6 +784,12 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, "node_modules/colorjs.io": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz", @@ -790,6 +797,17 @@ "license": "MIT", "peer": true }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -855,6 +873,16 @@ "sass-embedded": "^1.97.2" } }, + "node_modules/feather-icons": { + "version": "4.29.2", + "resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz", + "integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.5", + "core-js": "^3.1.3" + } + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -887,9 +915,9 @@ } }, "node_modules/immutable": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", - "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==", + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", "license": "MIT" }, "node_modules/is-core-module": { diff --git a/package.json b/package.json index 64e6fca..5a786ac 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", - "esbuild-sass-plugin": "^3.6.0" + "esbuild-sass-plugin": "^3.6.0", + "feather-icons": "^4.29.2" } } diff --git a/services/sites/services.go b/services/sites/services.go index 86e34b2..4585d03 100644 --- a/services/sites/services.go +++ b/services/sites/services.go @@ -9,6 +9,7 @@ import ( "github.com/gofiber/fiber/v3" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/pkg/modash/moslice" ) type Service struct { @@ -25,6 +26,22 @@ func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) { return s.db.HasUsersAndSites(ctx) } +func (s *Service) ListSites(ctx context.Context) ([]models.Site, error) { + user, ok := models.GetUser(ctx) + if !ok { + return nil, models.UserRequiredError + } + + sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID) + if err != nil { + return nil, err + } else if len(sites) == 0 { + return nil, errors.New("no sites found") + } + + return sites, nil +} + func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) { sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID) if err != nil { @@ -36,16 +53,20 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, return sites[0], nil } -type FirstRunRequest struct { - Username string `form:"username"` - Password1 string `form:"password1"` - Password2 string `form:"password2"` +type CreateSiteParams struct { SiteName string `form:"siteName"` SiteURL string `form:"siteUrl"` NetlifySiteID string `form:"netlifySiteId"` NetlifyAPIKey string `form:"netlifyAPIToken"` } +type FirstRunRequest struct { + CreateSiteParams + Username string `form:"username"` + Password1 string `form:"password1"` + Password2 string `form:"password2"` +} + func (frr FirstRunRequest) Validate() error { return validation.ValidateStruct(&frr, validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)), @@ -76,16 +97,31 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo return newUser, newSite, err } + ctx = models.WithUser(ctx, newUser) + newSite, err = s.CreateSite(ctx, req.CreateSiteParams) + if err != nil { + return newUser, newSite, err + } + + return newUser, newSite, nil +} + +func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite models.Site, _ error) { + user, ok := models.GetUser(ctx) + if !ok { + return newSite, models.UserRequiredError + } + newSite = models.Site{ Title: defaultIfEmpty(req.SiteName, "New Site"), GUID: models.NewNanoID(), - OwnerID: newUser.ID, + OwnerID: user.ID, Timezone: "UTC", PostsPerPage: 10, Created: time.Now(), } if err := s.db.SaveSite(ctx, &newSite); err != nil { - return newUser, newSite, err + return newSite, err } hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != "" @@ -100,11 +136,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo TargetKey: req.NetlifyAPIKey, } if err := s.db.SavePublishTarget(ctx, &target); err != nil { - return newUser, newSite, err + return newSite, err } } - return newUser, newSite, nil + return newSite, nil } func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) { @@ -166,3 +202,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti return site, nil } + +func (s *Service) BestPubTarget(ctx context.Context, site models.Site) (models.SitePublishTarget, error) { + pubTargets, err := s.db.SelectPublishTargetsOfSite(ctx, site.ID) + if err != nil { + return models.SitePublishTarget{}, err + } + + enabledPubTargets := moslice.Filter(pubTargets, func(pubTarget models.SitePublishTarget) bool { return pubTarget.Enabled }) + if len(enabledPubTargets) == 0 { + return models.SitePublishTarget{}, errors.New("no publish targets found") + } + + return enabledPubTargets[0], nil +} diff --git a/views/_common/nav.html b/views/_common/nav.html index e9c0de7..5005326 100644 --- a/views/_common/nav.html +++ b/views/_common/nav.html @@ -29,7 +29,25 @@ Publishing...
    --> - + +
    + {{ end }}
    diff --git a/models/pubmodel/sites.go b/models/pubmodel/sites.go index 38ba614..9f25b2f 100644 --- a/models/pubmodel/sites.go +++ b/models/pubmodel/sites.go @@ -6,6 +6,7 @@ import ( "iter" "lmika.dev/lmika/weiro/models" + "lmika.dev/pkg/modash/moslice" ) type Site struct { @@ -20,3 +21,7 @@ type Site struct { CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error) Pages []*models.Page } + +func (s Site) NavItems() []*models.Page { + return moslice.Filter(s.Pages, func(p *models.Page) bool { return p.ShowInNav }) +} From 18f9f49c0a7dae500501208ef9153c7cbbb525ac Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 25 Mar 2026 21:09:57 +1100 Subject: [PATCH 50/62] Started UI for editing images --- assets/css/main.scss | 13 ++++-- assets/js/controllers/edit_upload.js | 59 ++++++++++++++++++++++++ assets/js/main.js | 2 + cmds/server.go | 1 + handlers/posts.go | 4 +- handlers/uploads.go | 21 +++++++++ package-lock.json | 67 +++++++++++++++++++++++++++- package.json | 3 +- views/posts/edit.html | 2 +- views/uploads/edit.html | 28 ++++++++++++ 10 files changed, 191 insertions(+), 9 deletions(-) create mode 100644 assets/js/controllers/edit_upload.js create mode 100644 views/uploads/edit.html diff --git a/assets/css/main.scss b/assets/css/main.scss index 2e0883a..addf5ce 100644 --- a/assets/css/main.scss +++ b/assets/css/main.scss @@ -31,19 +31,24 @@ $container-max-widths: ( font-size: 0.9rem; } -// Post form +// Large editor +// +// Used for edit canvases which take up the entire window -// Post edit page styling -.post-edit-page { +.large-editor { height: 100vh; } -.post-edit-page main { +.large-editor main { display: flex; flex-direction: column; overflow: hidden; } +// Post form + +// Post edit page styling + .post-edit-page .post-form { flex: 1; display: flex; diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js new file mode 100644 index 0000000..11ed862 --- /dev/null +++ b/assets/js/controllers/edit_upload.js @@ -0,0 +1,59 @@ +import Handlebars from "handlebars"; +import {Controller} from "@hotwired/stimulus"; + +const processorFrame = Handlebars.compile(` +
    +
    + {{name}} + X +
    +
    + {{{props}}} +
    +
    +`); + +const processors = [ + { + name: "shadow", + label: "Shadow", + template: Handlebars.compile(`This processor has no properties.`), + }, + { + name: "resize", + label: "Resize", + template: Handlebars.compile(` +
    + + +
    +
    + + +
    + `), + } +]; + +export default class UploadEditController extends Controller { + static targets = ['processList']; + + connect() { + this._rebuildProcessList(); + } + + _rebuildProcessList() { + let el = this.processListTarget; + + // TEMP + let cardTemplate = processors[0].template({ + "id": "shadow", + }); + let cardOuter = processorFrame({ + name: processors[0].label, + props: cardTemplate, + }); + el.innerHTML = cardOuter; + // END TEMP + } +} \ No newline at end of file diff --git a/assets/js/main.js b/assets/js/main.js index fcbe286..d3ff4c6 100644 --- a/assets/js/main.js +++ b/assets/js/main.js @@ -8,6 +8,7 @@ import LogoutController from "./controllers/logout"; import FirstRunController from "./controllers/firstrun"; import UploadController from "./controllers/upload"; import ShowUploadController from "./controllers/show_upload"; +import EditUploadController from "./controllers/edit_upload"; import PagelistController from "./controllers/pagelist"; window.Stimulus = Application.start() @@ -18,6 +19,7 @@ Stimulus.register("logout", LogoutController); Stimulus.register("first-run", FirstRunController); Stimulus.register("upload", UploadController); Stimulus.register("show-upload", ShowUploadController); +Stimulus.register("edit-upload", EditUploadController); Stimulus.register("pagelist", PagelistController); feather.replace(); \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 36e5923..06f7352 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -149,6 +149,7 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/uploads/pending/:guid", uh.UploadPart) siteGroup.Post("/uploads/pending/:guid/finalize", uh.UploadComplete) siteGroup.Delete("/uploads/:uploadID", uh.Delete) + siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) diff --git a/handlers/posts.go b/handlers/posts.go index 3326533..0e491aa 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -75,7 +75,7 @@ func (ph PostsHandler) New(c fiber.Ctx) error { "post": p, "categories": cats, "selectedCategories": map[int64]bool{}, - "bodyClass": "post-edit-page", + "bodyClass": "large-editor", }) } @@ -116,7 +116,7 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error { "post": post, "categories": cats, "selectedCategories": selectedCategories, - "bodyClass": "post-edit-page", + "bodyClass": "large-editor", }) })) } diff --git a/handlers/uploads.go b/handlers/uploads.go index fa2cb98..3553b09 100644 --- a/handlers/uploads.go +++ b/handlers/uploads.go @@ -162,3 +162,24 @@ func (uh UploadsHandler) UploadComplete(c fiber.Ctx) error { return c.Status(fiber.StatusAccepted).JSON(fiber.Map{}) } + +func (uh UploadsHandler) Edit(c fiber.Ctx) error { + uploadIDStr := c.Params("uploadID") + if uploadIDStr == "" { + return fiber.ErrBadRequest + } + uploadID, err := strconv.ParseInt(uploadIDStr, 10, 64) + if err != nil { + return fiber.ErrBadRequest + } + + upload, err := uh.UploadsService.FetchUpload(c.Context(), uploadID) + if err != nil { + return err + } + + return c.Render("uploads/edit", fiber.Map{ + "upload": upload, + "bodyClass": "large-editor", + }) +} diff --git a/package-lock.json b/package-lock.json index 2068fd3..eadf529 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,8 @@ "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0", - "feather-icons": "^4.29.2" + "feather-icons": "^4.29.2", + "handlebars": "^4.7.8" }, "devDependencies": { "esbuild": "0.27.3" @@ -892,6 +893,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -958,6 +980,21 @@ "node": ">=0.10.0" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -1395,6 +1432,15 @@ "node": ">=14.0.0" } }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1462,12 +1508,31 @@ "license": "0BSD", "peer": true }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/varint": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz", "integrity": "sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==", "license": "MIT", "peer": true + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" } } } diff --git a/package.json b/package.json index 5a786ac..3455630 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0", - "feather-icons": "^4.29.2" + "feather-icons": "^4.29.2", + "handlebars": "^4.7.8" } } diff --git a/views/posts/edit.html b/views/posts/edit.html index b9f5ea7..fbb94fa 100644 --- a/views/posts/edit.html +++ b/views/posts/edit.html @@ -1,5 +1,5 @@ {{ $isPublished := ne .post.State 1 }} -
    +
    +
    +
    +
    + {{ .upload.Upload.Alt }} +
    +
    +
    + +
    + +
    +
    +
    +
    +
    + Actions go here +
    +
    \ No newline at end of file From 036b683eab6a9da873ea03c4ed6fd17cb5263497 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Wed, 25 Mar 2026 22:35:53 +1100 Subject: [PATCH 51/62] Have got session creation working --- assets/js/controllers/edit_upload.js | 25 +++++- cmds/server.go | 4 + handlers/imageedit.go | 67 ++++++++++++++++ models/errors.go | 1 + models/imgedit.go | 59 ++++++++++++++ services/imgedit/processing.go | 91 +++++++++++++++++++++ services/imgedit/service.go | 116 +++++++++++++++++++++++++++ services/imgedit/store.go | 66 +++++++++++++++ services/services.go | 4 + views/uploads/edit.html | 8 +- 10 files changed, 438 insertions(+), 3 deletions(-) create mode 100644 handlers/imageedit.go create mode 100644 models/imgedit.go create mode 100644 services/imgedit/processing.go create mode 100644 services/imgedit/service.go create mode 100644 services/imgedit/store.go diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 11ed862..35be2f7 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -36,10 +36,15 @@ const processors = [ ]; export default class UploadEditController extends Controller { - static targets = ['processList']; + static targets = ['processList', 'preview']; + static values = { + uploadId: Number, + siteId: Number, + }; connect() { this._rebuildProcessList(); + this._createSession(); } _rebuildProcessList() { @@ -56,4 +61,22 @@ export default class UploadEditController extends Controller { el.innerHTML = cardOuter; // END TEMP } + + async _createSession() { + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, { + method: 'POST', + body: JSON.stringify({ + "base_upload": this.uploadIdValue, + }) + }); + + this._state = await resp.json(); + this.previewTarget.src = this._state.preview_url; + + console.log("Session created"); + } catch (e) { + console.error(e); + } + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 06f7352..5870a8a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -111,6 +111,7 @@ Starting weiro without any arguments will start the server. lh := handlers.LoginHandler{Config: cfg, AuthService: svcs.Auth} ph := handlers.PostsHandler{PostService: svcs.Posts, CategoryService: svcs.Categories} uh := handlers.UploadsHandler{UploadsService: svcs.Uploads} + ieh := handlers.ImageEditHandlers{ImageEditService: svcs.ImageEdit} ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} pgh := handlers.PagesHandler{PageService: svcs.Pages} @@ -151,6 +152,9 @@ Starting weiro without any arguments will start the server. siteGroup.Delete("/uploads/:uploadID", uh.Delete) siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) + siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) + siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) diff --git a/handlers/imageedit.go b/handlers/imageedit.go new file mode 100644 index 0000000..551776a --- /dev/null +++ b/handlers/imageedit.go @@ -0,0 +1,67 @@ +package handlers + +import ( + "bufio" + "io" + "log" + "net/http" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/imgedit" +) + +type ImageEditHandlers struct { + ImageEditService *imgedit.Service +} + +func (ieh ImageEditHandlers) Create(c fiber.Ctx) error { + var req struct { + BaseUploadID int64 `json:"base_upload"` + } + + if err := c.Bind().JSON(&req); err != nil { + return err + } + + res, err := ieh.ImageEditService.NewSession(c.Context(), req.BaseUploadID) + if err != nil { + return err + } + + var resp = struct { + Session models.ImageEditSession `json:"session"` + PreviewURL string `json:"preview_url"` + }{ + Session: res, + PreviewURL: res.PreviewURL(), + } + + return c.Status(http.StatusCreated).JSON(resp) +} + +func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error { + log.Printf("Previewing image edit session %v/%v", c.Params("sessionID"), c.Params("versionID")) + sessionID := c.Params("sessionID") + versionID := c.Params("versionID") + + mimeTime, rw, err := ieh.ImageEditService.LoadImageVersion(c.Context(), sessionID, versionID) + if err != nil { + return err + } + + c.Set("Content-Type", mimeTime) + c.Status(http.StatusOK) + return c.SendStreamWriter(func(w *bufio.Writer) { + rw, err := rw() + if err != nil { + return + } + defer rw.Close() + + _, err = io.Copy(w, rw) + if err != nil { + return + } + }) +} diff --git a/models/errors.go b/models/errors.go index 3efadbc..2c4ae68 100644 --- a/models/errors.go +++ b/models/errors.go @@ -8,3 +8,4 @@ var NotFoundError = errors.New("not found") var SiteRequiredError = errors.New("site required") var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds") var SlugConflictError = errors.New("a record with this slug already exists") +var UnsupportedImageFormat = errors.New("unsupported image format") diff --git a/models/imgedit.go b/models/imgedit.go new file mode 100644 index 0000000..88ec8be --- /dev/null +++ b/models/imgedit.go @@ -0,0 +1,59 @@ +package models + +import ( + "crypto/md5" + "encoding/json" + "fmt" + "strings" + "time" +) + +type ImageEditSession struct { + GUID string `json:"guid"` + SiteID int64 `json:"siteId"` + UserID int64 `json:"userId"` + BaseUploadID int64 `json:"baseUploadId"` + ImageExt string `json:"imageExt"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + Processors []ImageEditProcessor `json:"processors"` +} + +func (ieh ImageEditSession) PreviewURL() string { + return fmt.Sprintf("/sites/%v/imageedit/%v/preview/%v", ieh.SiteID, ieh.GUID, ieh.Processors[len(ieh.Processors)-1].VersionID) +} + +func (ieh *ImageEditSession) RecalcVersionIDs() { + for i, p := range ieh.Processors { + if i == 0 { + p.SetVersionID("") + } else { + p.SetVersionID(ieh.Processors[i-1].VersionID) + } + + ieh.Processors[i] = p + } +} + +type ImageEditProcessor struct { + Type string `json:"type"` + Props json.RawMessage `json:"props"` + + // VersionID is a unique hash of the particular processor. This includes the version ID of the previous processor, + // thereby causing a change of one processor to affect the version IDs of processors down the line. + VersionID string `json:"versionId"` +} + +func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) { + var sb strings.Builder + sb.WriteString(previousVersionID) + sb.WriteString("-") + sb.WriteString(ieh.Type) + sb.WriteString("-") + sb.WriteString(string(ieh.Props)) + ieh.VersionID = fmt.Sprintf("%x", md5.Sum([]byte(sb.String()))) +} + +type CopyUploadProps struct { + UploadID int64 `json:"uploadId"` +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go new file mode 100644 index 0000000..83e58a9 --- /dev/null +++ b/services/imgedit/processing.go @@ -0,0 +1,91 @@ +package imgedit + +import ( + "context" + "encoding/json" + "fmt" + "image" + "os" + "path/filepath" + + "github.com/disintegration/imaging" + "lmika.dev/lmika/weiro/models" +) + +func (s *Service) reprocess(ctx context.Context, session models.ImageEditSession) (imageSource, error) { + var img imageSource + + for _, p := range session.Processors { + // Check if there's currently a cached image of this processor + cachedImageFile := filepath.Join(s.scratchDir, session.GUID, fmt.Sprintf("%v.%v", p.VersionID, session.ImageExt)) + if s, err := os.Stat(cachedImageFile); err == nil && !s.IsDir() { + img = fileImageSource(cachedImageFile) + continue + } + + // Need to process the image + var srcImg image.Image + if img != nil { + var err error + srcImg, err = img.image() + if err != nil { + return nil, err + } + } + + resImg, err := s.processImage(ctx, srcImg, p) + if err != nil { + return nil, err + } + + // Cache the processed image + if err := imaging.Save(resImg, cachedImageFile); err != nil { + return nil, err + } + img = imageImageSource{resImg} + } + + return img, nil +} + +func (s *Service) processImage(ctx context.Context, srcImg image.Image, processor models.ImageEditProcessor) (image.Image, error) { + switch processor.Type { + case "copy-upload": + var p models.CopyUploadProps + if err := json.Unmarshal(processor.Props, &p); err != nil { + return nil, err + } + + _, rc, err := s.uploadService.OpenUpload(ctx, p.UploadID) + if err != nil { + return nil, err + } + + f, err := rc() + if err != nil { + return nil, err + } + defer f.Close() + + return imaging.Decode(f) + } + return nil, fmt.Errorf("unknown processor type: %v", processor.Type) +} + +type imageSource interface { + image() (image.Image, error) +} + +type fileImageSource string + +func (f fileImageSource) image() (image.Image, error) { + return imaging.Open(string(f)) +} + +type imageImageSource struct { + img image.Image +} + +func (i imageImageSource) image() (image.Image, error) { + return i.img, nil +} diff --git a/services/imgedit/service.go b/services/imgedit/service.go new file mode 100644 index 0000000..0b4d080 --- /dev/null +++ b/services/imgedit/service.go @@ -0,0 +1,116 @@ +package imgedit + +import ( + "context" + "encoding/json" + "io" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/uploads" +) + +type Service struct { + scratchDir string + uploadService *uploads.Service + sessionStore *sessionStore +} + +func New( + uploadService *uploads.Service, + scratchDir string, +) *Service { + return &Service{ + scratchDir: scratchDir, + uploadService: uploadService, + sessionStore: &sessionStore{baseDir: scratchDir}, + } +} + +func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return "", nil, err + } + + session, err := s.sessionStore.get(sessionID) + if err != nil { + return "", nil, err + } else if session.SiteID != site.ID || session.UserID != user.ID { + return "", nil, models.PermissionError + } + + return s.sessionStore.getImage(session, versionID+"."+session.ImageExt) +} + +func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.ImageEditSession, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.ImageEditSession{}, err + } + + upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID) + if err != nil { + return models.ImageEditSession{}, err + } + + var ext string + switch upload.MIMEType { + case "image/jpeg": + ext = "jpg" + case "image/png": + ext = "png" + default: + return models.ImageEditSession{}, models.UnsupportedImageFormat + } + + newSession := models.ImageEditSession{ + GUID: models.NewNanoID(), + SiteID: site.ID, + UserID: user.ID, + BaseUploadID: baseUploadID, + ImageExt: ext, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + Processors: []models.ImageEditProcessor{ + { + Type: "copy-upload", + Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}), + }, + }, + } + + newSession.RecalcVersionIDs() + if err := s.sessionStore.create(newSession); err != nil { + return models.ImageEditSession{}, err + } + + if _, err := s.reprocess(ctx, newSession); err != nil { + return models.ImageEditSession{}, err + } + + return newSession, nil +} + +func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) { + user, ok := models.GetUser(ctx) + if !ok { + return models.Site{}, models.User{}, models.UserRequiredError + } + + site, ok := models.GetSite(ctx) + if !ok { + return models.Site{}, models.User{}, models.SiteRequiredError + } + + if site.OwnerID != user.ID { + return models.Site{}, models.User{}, models.PermissionError + } + + return site, user, nil +} + +func mustToJSON(a any) json.RawMessage { + b, _ := json.Marshal(a) + return b +} diff --git a/services/imgedit/store.go b/services/imgedit/store.go new file mode 100644 index 0000000..b697faa --- /dev/null +++ b/services/imgedit/store.go @@ -0,0 +1,66 @@ +package imgedit + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + + "lmika.dev/lmika/weiro/models" +) + +type sessionStore struct { + baseDir string +} + +func (ss *sessionStore) create(newSession models.ImageEditSession) error { + sessionMeta, err := json.Marshal(newSession) + if err != nil { + return err + } + + if err := os.MkdirAll(filepath.Join(ss.baseDir, newSession.GUID), 0755); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(ss.baseDir, newSession.GUID, "session.json"), sessionMeta, 0644); err != nil { + return err + } + return nil +} + +func (ss *sessionStore) get(guid string) (models.ImageEditSession, error) { + sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json")) + if err != nil { + return models.ImageEditSession{}, err + } + + sessionData := models.ImageEditSession{} + if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil { + return models.ImageEditSession{}, err + } + + return sessionData, nil +} + +func (ss *sessionStore) getImage(session models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { + fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename) + if s, err := os.Stat(fullPath); err != nil { + return "", nil, err + } else if s.IsDir() { + return "", nil, os.ErrNotExist + } + + var mimeType string + switch filepath.Ext(imageFilename) { + case ".jpg", ".jpeg": + mimeType = "image/jpeg" + case ".png": + mimeType = "image/png" + default: + return "", nil, models.UnsupportedImageFormat + } + + return mimeType, func() (io.ReadCloser, error) { + return os.Open(fullPath) + }, nil +} diff --git a/services/services.go b/services/services.go index 852dea3..ab1a4ca 100644 --- a/services/services.go +++ b/services/services.go @@ -8,6 +8,7 @@ import ( "lmika.dev/lmika/weiro/providers/uploadfiles" "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/categories" + "lmika.dev/lmika/weiro/services/imgedit" "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" @@ -23,6 +24,7 @@ type Services struct { Posts *posts.Service Sites *sites.Service Uploads *uploads.Service + ImageEdit *imgedit.Service Categories *categories.Service Pages *pages.Service } @@ -41,6 +43,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")) + imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit")) categoriesService := categories.New(dbp, publisherQueue) pagesService := pages.New(dbp, publisherQueue) @@ -52,6 +55,7 @@ func New(cfg config.Config) (*Services, error) { Posts: postService, Sites: siteService, Uploads: uploadService, + ImageEdit: imageEditService, Categories: categoriesService, Pages: pagesService, }, nil diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 18ff35f..3137e00 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -1,8 +1,12 @@
    -
    +
    - {{ .upload.Upload.Alt }} + {{ .upload.Upload.Alt }}
    From 599c72d465561a4f94b247fc0895dcfe05f8dec2 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Mar 2026 21:16:50 +1100 Subject: [PATCH 52/62] Have got the processor plumbing working --- assets/js/controllers/edit_upload.js | 29 ++++++++++ cmds/server.go | 1 + handlers/imageedit.go | 34 +++++++++-- handlers/index.go | 8 +++ handlers/middleware/site.go | 4 ++ models/imgedit.go | 3 + services/imgedit/processing.go | 7 ++- services/imgedit/service.go | 87 ++++++++++++++++++++-------- services/imgedit/shadow.go | 35 +++++++++++ services/imgedit/store.go | 12 ++-- views/uploads/edit.html | 4 +- 11 files changed, 185 insertions(+), 39 deletions(-) create mode 100644 services/imgedit/shadow.go diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 35be2f7..65b0234 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -47,6 +47,13 @@ export default class UploadEditController extends Controller { this._createSession(); } + async addProcessor(ev) { + ev.preventDefault(); + await this._addProcessor({ + type: "shadow" + }); + } + _rebuildProcessList() { let el = this.processListTarget; @@ -66,6 +73,10 @@ export default class UploadEditController extends Controller { try { let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/`, { method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, body: JSON.stringify({ "base_upload": this.uploadIdValue, }) @@ -79,4 +90,22 @@ export default class UploadEditController extends Controller { console.error(e); } } + + async _addProcessor(processor) { + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(processor) + }); + + this._state = await resp.json(); + this.previewTarget.src = this._state.preview_url; + } catch (e) { + console.error(e); + } + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index 5870a8a..d776cf3 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -153,6 +153,7 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) siteGroup.Get("/settings", ssh.General) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index 551776a..1ca9817 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -15,6 +15,11 @@ type ImageEditHandlers struct { ImageEditService *imgedit.Service } +type sessionResponse struct { + Session *models.ImageEditSession `json:"session"` + PreviewURL string `json:"preview_url"` +} + func (ieh ImageEditHandlers) Create(c fiber.Ctx) error { var req struct { BaseUploadID int64 `json:"base_upload"` @@ -29,10 +34,7 @@ func (ieh ImageEditHandlers) Create(c fiber.Ctx) error { return err } - var resp = struct { - Session models.ImageEditSession `json:"session"` - PreviewURL string `json:"preview_url"` - }{ + var resp = sessionResponse{ Session: res, PreviewURL: res.PreviewURL(), } @@ -65,3 +67,27 @@ func (ieh ImageEditHandlers) Preview(c fiber.Ctx) error { } }) } + +func (ieh ImageEditHandlers) AddProcessor(c fiber.Ctx) error { + sessionID := c.Params("sessionID") + if sessionID == "" { + log.Println("No session ID") + return fiber.ErrBadRequest + } + + var req imgedit.AddProcessorReq + if err := c.Bind().Body(&req); err != nil { + log.Printf("Failed to parse request body: %v", err) + return fiber.ErrBadRequest + } + + res, err := ieh.ImageEditService.AddProcessor(c.Context(), sessionID, req) + if err != nil { + return err + } + + return c.Status(http.StatusOK).JSON(sessionResponse{ + Session: res, + PreviewURL: res.PreviewURL(), + }) +} diff --git a/handlers/index.go b/handlers/index.go index 6062237..410c347 100644 --- a/handlers/index.go +++ b/handlers/index.go @@ -2,6 +2,7 @@ package handlers import ( "fmt" + "log" "net/url" "regexp" @@ -37,6 +38,13 @@ func (h IndexHandler) Index(c fiber.Ctx) error { } } + sess := session.FromContext(c) + lastSiteID, ok := sess.Get("last_site_id").(int64) + log.Printf("last site id: %v", lastSiteID) + if ok { + return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", lastSiteID)) + } + site, err := h.SiteService.BestSite(c.Context(), user) if err != nil { return err diff --git a/handlers/middleware/site.go b/handlers/middleware/site.go index 6f47430..1d3ddf2 100644 --- a/handlers/middleware/site.go +++ b/handlers/middleware/site.go @@ -5,6 +5,7 @@ import ( "emperror.dev/errors" "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/middleware/session" "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/services/sites" @@ -41,6 +42,9 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error { } c.Locals("allSites", sitesOwnedByUser) + sess := session.FromContext(c) + sess.Set("last_site_id", siteID) + if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil { c.Locals("pubTarget", pubTargets) } diff --git a/models/imgedit.go b/models/imgedit.go index 88ec8be..b954402 100644 --- a/models/imgedit.go +++ b/models/imgedit.go @@ -36,6 +36,7 @@ func (ieh *ImageEditSession) RecalcVersionIDs() { } type ImageEditProcessor struct { + ID string `json:"id"` Type string `json:"type"` Props json.RawMessage `json:"props"` @@ -46,6 +47,8 @@ type ImageEditProcessor struct { func (ieh *ImageEditProcessor) SetVersionID(previousVersionID string) { var sb strings.Builder + sb.WriteString(ieh.ID) + sb.WriteString("-") sb.WriteString(previousVersionID) sb.WriteString("-") sb.WriteString(ieh.Type) diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 83e58a9..107bcb3 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "image" + "image/color" "os" "path/filepath" @@ -12,7 +13,7 @@ import ( "lmika.dev/lmika/weiro/models" ) -func (s *Service) reprocess(ctx context.Context, session models.ImageEditSession) (imageSource, error) { +func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSession) (imageSource, error) { var img imageSource for _, p := range session.Processors { @@ -68,6 +69,10 @@ func (s *Service) processImage(ctx context.Context, srcImg image.Image, processo defer f.Close() return imaging.Decode(f) + case "shadow": + shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) + composit := imaging.OverlayCenter(shadow, srcImg, 1.0) + return composit, nil } return nil, fmt.Errorf("unknown processor type: %v", processor.Type) } diff --git a/services/imgedit/service.go b/services/imgedit/service.go index 0b4d080..fa6b795 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -27,31 +27,15 @@ func New( } } -func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) { +func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (*models.ImageEditSession, error) { site, user, err := s.fetchSiteAndUser(ctx) if err != nil { - return "", nil, err - } - - session, err := s.sessionStore.get(sessionID) - if err != nil { - return "", nil, err - } else if session.SiteID != site.ID || session.UserID != user.ID { - return "", nil, models.PermissionError - } - - return s.sessionStore.getImage(session, versionID+"."+session.ImageExt) -} - -func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.ImageEditSession, error) { - site, user, err := s.fetchSiteAndUser(ctx) - if err != nil { - return models.ImageEditSession{}, err + return nil, err } upload, _, err := s.uploadService.OpenUpload(ctx, baseUploadID) if err != nil { - return models.ImageEditSession{}, err + return nil, err } var ext string @@ -61,7 +45,7 @@ func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.Im case "image/png": ext = "png" default: - return models.ImageEditSession{}, models.UnsupportedImageFormat + return nil, models.UnsupportedImageFormat } newSession := models.ImageEditSession{ @@ -74,6 +58,7 @@ func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.Im UpdatedAt: time.Now().UTC(), Processors: []models.ImageEditProcessor{ { + ID: models.NewNanoID(), Type: "copy-upload", Props: mustToJSON(models.CopyUploadProps{UploadID: baseUploadID}), }, @@ -81,15 +66,67 @@ func (s *Service) NewSession(ctx context.Context, baseUploadID int64) (models.Im } newSession.RecalcVersionIDs() - if err := s.sessionStore.create(newSession); err != nil { - return models.ImageEditSession{}, err + if err := s.sessionStore.save(&newSession); err != nil { + return nil, err } - if _, err := s.reprocess(ctx, newSession); err != nil { - return models.ImageEditSession{}, err + if _, err := s.reprocess(ctx, &newSession); err != nil { + return nil, err } - return newSession, nil + return &newSession, nil +} + +func (s *Service) LoadImageVersion(ctx context.Context, sessionID string, versionID string) (mimeType string, rw func() (io.ReadCloser, error), err error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return "", nil, err + } + + return s.sessionStore.getImage(session, versionID+"."+session.ImageExt) +} + +type AddProcessorReq struct { + Type string `json:"type"` +} + +func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddProcessorReq) (*models.ImageEditSession, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + // TODO: verify processor, etc. + session.Processors = append(session.Processors, models.ImageEditProcessor{ + ID: models.NewNanoID(), + Type: req.Type, + }) + + session.RecalcVersionIDs() + if err := s.sessionStore.save(session); err != nil { + return nil, err + } + + if _, err := s.reprocess(ctx, session); err != nil { + return nil, err + } + + return session, nil +} + +func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { + site, user, err := s.fetchSiteAndUser(ctx) + if err != nil { + return nil, err + } + + session, err := s.sessionStore.get(sessionID) + if err != nil { + return nil, err + } else if session.SiteID != site.ID || session.UserID != user.ID { + return nil, models.PermissionError + } + return session, nil } func (s *Service) fetchSiteAndUser(ctx context.Context) (models.Site, models.User, error) { diff --git a/services/imgedit/shadow.go b/services/imgedit/shadow.go new file mode 100644 index 0000000..4a308d0 --- /dev/null +++ b/services/imgedit/shadow.go @@ -0,0 +1,35 @@ +package imgedit + +import ( + "image" + "image/color" + + "github.com/disintegration/imaging" +) + +func makeBoxShadow(maskImg image.Image, shadowColor color.Color, sigma float64, shadowMargin, offsetY int) image.Image { + w, h := maskImg.Bounds().Dx(), maskImg.Bounds().Dy() + cr, cg, cb, _ := shadowColor.RGBA() + cr8, cg8, cb8 := uint8(cr>>8), uint8(cg>>8), uint8(cb>>8) + + // New box image + backing := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY)) + newImg := image.NewNRGBA(image.Rect(0, 0, w+shadowMargin*2, h+shadowMargin*2+offsetY)) + for x := 0; x < w+shadowMargin*2; x++ { + for y := 0; y < h+shadowMargin*2; y++ { + var c = color.NRGBA{R: 255, G: 255, B: 255, A: 0} + if x >= shadowMargin-4 && y >= shadowMargin-4 && x <= w+shadowMargin+4 && y <= h+shadowMargin+4 { + _, _, _, a := maskImg.At(x-shadowMargin, y-shadowMargin).RGBA() + c = color.NRGBA{R: cr8, G: cg8, B: cb8, A: uint8(a >> 8)} + } + backing.SetNRGBA(x, y, color.NRGBA{R: 255, G: 255, B: 255, A: 0}) + newImg.SetNRGBA(x, y+offsetY, c) + } + } + + // Blur + blurredImage := imaging.Blur(newImg, sigma) + backing = imaging.OverlayCenter(backing, blurredImage, 0.6) + + return backing +} diff --git a/services/imgedit/store.go b/services/imgedit/store.go index b697faa..7638dbe 100644 --- a/services/imgedit/store.go +++ b/services/imgedit/store.go @@ -13,7 +13,7 @@ type sessionStore struct { baseDir string } -func (ss *sessionStore) create(newSession models.ImageEditSession) error { +func (ss *sessionStore) save(newSession *models.ImageEditSession) error { sessionMeta, err := json.Marshal(newSession) if err != nil { return err @@ -28,21 +28,21 @@ func (ss *sessionStore) create(newSession models.ImageEditSession) error { return nil } -func (ss *sessionStore) get(guid string) (models.ImageEditSession, error) { +func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) { sessionDataBts, err := os.ReadFile(filepath.Join(ss.baseDir, guid, "session.json")) if err != nil { - return models.ImageEditSession{}, err + return nil, err } sessionData := models.ImageEditSession{} if err := json.Unmarshal(sessionDataBts, &sessionData); err != nil { - return models.ImageEditSession{}, err + return nil, err } - return sessionData, nil + return &sessionData, nil } -func (ss *sessionStore) getImage(session models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { +func (ss *sessionStore) getImage(session *models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename) if s, err := os.Stat(fullPath); err != nil { return "", nil, err diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 3137e00..5c8cc2d 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -17,9 +17,7 @@ Add Processor
    From 2d42a0ef909859a7b0fdd6fe249ab836103562c0 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Mar 2026 21:44:20 +1100 Subject: [PATCH 53/62] Have got removing parameters working --- assets/js/controllers/edit_upload.js | 77 ++++++++++++++++++++-------- cmds/server.go | 1 + handlers/imageedit.go | 22 ++++++++ services/imgedit/processing.go | 3 ++ services/imgedit/service.go | 20 ++++++++ 5 files changed, 103 insertions(+), 20 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 65b0234..929b905 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -5,7 +5,10 @@ const processorFrame = Handlebars.compile(`
    {{name}} - X + X
    {{{props}}} @@ -13,14 +16,12 @@ const processorFrame = Handlebars.compile(`
    `); -const processors = [ - { - name: "shadow", +const processorUIs = { + "shadow": { label: "Shadow", template: Handlebars.compile(`This processor has no properties.`), }, - { - name: "resize", + "resize": { label: "Resize", template: Handlebars.compile(`
    @@ -32,8 +33,8 @@ const processors = [
    `), - } -]; + }, +}; export default class UploadEditController extends Controller { static targets = ['processList', 'preview']; @@ -54,19 +55,33 @@ export default class UploadEditController extends Controller { }); } + async removeProcessor(ev) { + ev.preventDefault(); + let id = ev.params.id; + console.log(ev.params); + await this._removeProcessor(id); + } + _rebuildProcessList() { let el = this.processListTarget; - // TEMP - let cardTemplate = processors[0].template({ - "id": "shadow", - }); - let cardOuter = processorFrame({ - name: processors[0].label, - props: cardTemplate, - }); - el.innerHTML = cardOuter; - // END TEMP + if ((!this._state) || (!this._state.session) || (!this._state.session.processors)) { + return; + } + + el.innerHTML = ""; + for (let p of this._state.session.processors) { + let ui = processorUIs[p.type]; + if (!ui) { + continue; + } + let cardOuter = processorFrame({ + id: p.id, + name: ui.label, + props: ui.template(p), + }); + el.innerHTML += cardOuter; + } } async _createSession() { @@ -83,9 +98,9 @@ export default class UploadEditController extends Controller { }); this._state = await resp.json(); - this.previewTarget.src = this._state.preview_url; - console.log("Session created"); + this._rebuildProcessList(); + this.previewTarget.src = this._state.preview_url; } catch (e) { console.error(e); } @@ -103,9 +118,31 @@ export default class UploadEditController extends Controller { }); this._state = await resp.json(); + + this._rebuildProcessList(); this.previewTarget.src = this._state.preview_url; } catch (e) { console.error(e); } } + + async _removeProcessor(processorID) { + await this._doReturningState(async () => { + return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors/${processorID}`, { + method: 'DELETE', + })).json(); + }) + } + + async _doReturningState(fn) { + try { + this._state = await fn(); + + this._rebuildProcessList(); + this.previewTarget.src = this._state.preview_url; + } catch (e) { + console.error(e); + } + + } } \ No newline at end of file diff --git a/cmds/server.go b/cmds/server.go index d776cf3..515f7a5 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -154,6 +154,7 @@ Starting weiro without any arguments will start the server. siteGroup.Post("/imageedit", ieh.Create) siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) + siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) siteGroup.Get("/settings", ssh.General) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index 1ca9817..8026c53 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -91,3 +91,25 @@ func (ieh ImageEditHandlers) AddProcessor(c fiber.Ctx) error { PreviewURL: res.PreviewURL(), }) } + +func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error { + sessionID := c.Params("sessionID") + if sessionID == "" { + return fiber.ErrBadRequest + } + + processorID := c.Params("processorID") + if processorID == "" { + return fiber.ErrBadRequest + } + + res, err := ieh.ImageEditService.DeleteProcessor(c.Context(), sessionID, processorID) + if err != nil { + return err + } + + return c.Status(http.StatusOK).JSON(sessionResponse{ + Session: res, + PreviewURL: res.PreviewURL(), + }) +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 107bcb3..68f8b9d 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -6,6 +6,7 @@ import ( "fmt" "image" "image/color" + "log" "os" "path/filepath" @@ -46,6 +47,8 @@ func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSessio img = imageImageSource{resImg} } + log.Printf("result of processed image: %T", img) + return img, nil } diff --git a/services/imgedit/service.go b/services/imgedit/service.go index fa6b795..97603a8 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -8,6 +8,7 @@ import ( "lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/services/uploads" + "lmika.dev/pkg/modash/moslice" ) type Service struct { @@ -114,6 +115,25 @@ func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddPro return session, nil } +func (s *Service) DeleteProcessor(ctx context.Context, sessionID, processorID string) (*models.ImageEditSession, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + session.Processors = moslice.Filter(session.Processors, func(p models.ImageEditProcessor) bool { return p.ID != processorID }) + session.RecalcVersionIDs() + if err := s.sessionStore.save(session); err != nil { + return nil, err + } + + if _, err := s.reprocess(ctx, session); err != nil { + return nil, err + } + + return session, nil +} + func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { site, user, err := s.fetchSiteAndUser(ctx) if err != nil { From 488942db2e55d673c4e44893e17f1f54f3a8a894 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Thu, 26 Mar 2026 22:14:57 +1100 Subject: [PATCH 54/62] Started working on proper parameters --- assets/js/controllers/edit_upload.js | 13 ++++++-- services/imgedit/processing.go | 48 +++++++++++++++++++++++++--- services/imgedit/service.go | 18 +++++++++-- 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index 929b905..f1d472a 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -1,6 +1,10 @@ import Handlebars from "handlebars"; import {Controller} from "@hotwired/stimulus"; +Handlebars.registerHelper("submit_on", function (id, event) { + return `data-action="${event}->edit-upload#updateProcessor" data-edit-upload-id-param="${id}"` +}); + const processorFrame = Handlebars.compile(`
    @@ -11,7 +15,7 @@ const processorFrame = Handlebars.compile(` >X
    - {{{props}}} + {{{props}}}
    `); @@ -19,7 +23,12 @@ const processorFrame = Handlebars.compile(` const processorUIs = { "shadow": { label: "Shadow", - template: Handlebars.compile(`This processor has no properties.`), + template: Handlebars.compile(` +
    + + +
    + `), }, "resize": { label: "Resize", diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 68f8b9d..378ead5 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -14,6 +14,34 @@ import ( "lmika.dev/lmika/weiro/models" ) +type imageProcessor struct { + newParams func() any + processImage func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) +} + +type shadowProcessorArgs struct { + Color string `json:"color"` + OffsetY int `json:"offset_y"` +} + +var processors = map[string]imageProcessor{ + "shadow": { + newParams: func() any { + return &shadowProcessorArgs{ + Color: "#000000", + OffsetY: 0, + } + }, + processImage: func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) { + p := params.(*shadowProcessorArgs) + + shadow := makeBoxShadow(srcImg, color.Black, 4, 10, p.OffsetY) + composit := imaging.OverlayCenter(shadow, srcImg, 1.0) + return composit, nil + }, + }, +} + func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSession) (imageSource, error) { var img imageSource @@ -72,12 +100,22 @@ func (s *Service) processImage(ctx context.Context, srcImg image.Image, processo defer f.Close() return imaging.Decode(f) - case "shadow": - shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) - composit := imaging.OverlayCenter(shadow, srcImg, 1.0) - return composit, nil + //case "shadow": + // shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) + // composit := imaging.OverlayCenter(shadow, srcImg, 1.0) + // return composit, nil } - return nil, fmt.Errorf("unknown processor type: %v", processor.Type) + + proc, ok := processors[processor.Type] + if !ok { + return nil, fmt.Errorf("unknown processor type: %v", processor.Type) + } + + paramType := proc.newParams() + if err := json.Unmarshal(processor.Props, paramType); err != nil { + return nil, err + } + return proc.processImage(ctx, srcImg, paramType) } type imageSource interface { diff --git a/services/imgedit/service.go b/services/imgedit/service.go index 97603a8..d9f3ba4 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -3,6 +3,7 @@ package imgedit import ( "context" "encoding/json" + "fmt" "io" "time" @@ -97,10 +98,21 @@ func (s *Service) AddProcessor(ctx context.Context, sessionID string, req AddPro return nil, err } - // TODO: verify processor, etc. + proc, ok := processors[req.Type] + if !ok { + return nil, fmt.Errorf("unknown processor type: %v", req.Type) + } + + paramType := proc.newParams() + paramBytes, err := json.Marshal(paramType) + if err != nil { + return nil, err + } + session.Processors = append(session.Processors, models.ImageEditProcessor{ - ID: models.NewNanoID(), - Type: req.Type, + ID: models.NewNanoID(), + Type: req.Type, + Props: paramBytes, }) session.RecalcVersionIDs() From f9a65c8ca9b2d4c8808c960868d23e9b871c00b1 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Fri, 27 Mar 2026 21:43:03 +1100 Subject: [PATCH 55/62] Have got adjusting processor arguments working --- assets/js/controllers/edit_upload.js | 44 ++++++++++++++++++++++++--- cmds/server.go | 1 + handlers/imageedit.go | 29 ++++++++++++++++++ services/imgedit/processing.go | 45 ++++++++++++++++++++++++++-- services/imgedit/service.go | 29 ++++++++++++++++++ views/uploads/edit.html | 17 ++++++----- 6 files changed, 151 insertions(+), 14 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index f1d472a..f575bea 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -24,9 +24,17 @@ const processorUIs = { "shadow": { label: "Shadow", template: Handlebars.compile(` -
    - - +
    + +
    + +
    +
    +
    + +
    + +
    `), }, @@ -67,10 +75,19 @@ export default class UploadEditController extends Controller { async removeProcessor(ev) { ev.preventDefault(); let id = ev.params.id; - console.log(ev.params); await this._removeProcessor(id); } + async updateProcessor(ev) { + ev.preventDefault(); + let id = ev.params.id; + + let paramParentEl = ev.target.closest('[data-role="processor-params"]'); + let params = Object.fromEntries(new FormData(paramParentEl).entries()); + + await this._updateProcessor(id, params); + } + _rebuildProcessList() { let el = this.processListTarget; @@ -135,6 +152,25 @@ export default class UploadEditController extends Controller { } } + async _updateProcessor(processorID, params) { + await this._doReturningState(async () => { + return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}`, { + method: 'PATCH', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + processor: { + id: processorID, + props: params, + } + }) + })).json(); + }) + } + + async _removeProcessor(processorID) { await this._doReturningState(async () => { return (await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/processors/${processorID}`, { diff --git a/cmds/server.go b/cmds/server.go index 515f7a5..7a1445a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -153,6 +153,7 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/uploads/:uploadID/edit", uh.Edit) siteGroup.Post("/imageedit", ieh.Create) + siteGroup.Patch("/imageedit/:sessionID", ieh.PatchSession) siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index 8026c53..ced7f75 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -113,3 +113,32 @@ func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error { PreviewURL: res.PreviewURL(), }) } + +func (ieh ImageEditHandlers) PatchSession(c fiber.Ctx) error { + var req struct { + UpdateProc *imgedit.UpdateProcessorReq `json:"processor"` + } + + sessionID := c.Params("sessionID") + if sessionID == "" { + return fiber.ErrBadRequest + } + + if err := c.Bind().Body(&req); err != nil { + return err + } + log.Printf("Got request: %v", *req.UpdateProc) + + if req.UpdateProc != nil { + res, err := ieh.ImageEditService.UpdateProcessor(c.Context(), sessionID, *req.UpdateProc) + if err != nil { + return err + } + return c.Status(http.StatusOK).JSON(sessionResponse{ + Session: res, + PreviewURL: res.PreviewURL(), + }) + } + + return fiber.ErrBadRequest +} diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index 378ead5..c1f99bf 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -21,7 +21,7 @@ type imageProcessor struct { type shadowProcessorArgs struct { Color string `json:"color"` - OffsetY int `json:"offset_y"` + OffsetY int `json:"offset_y,string"` } var processors = map[string]imageProcessor{ @@ -35,7 +35,12 @@ var processors = map[string]imageProcessor{ processImage: func(ctx context.Context, srcImg image.Image, params any) (image.Image, error) { p := params.(*shadowProcessorArgs) - shadow := makeBoxShadow(srcImg, color.Black, 4, 10, p.OffsetY) + shadowColor, err := parseHexColor(p.Color) + if err != nil { + return nil, fmt.Errorf("invalid shadow color: %w", err) + } + + shadow := makeBoxShadow(srcImg, shadowColor, 4, 10, p.OffsetY) composit := imaging.OverlayCenter(shadow, srcImg, 1.0) return composit, nil }, @@ -135,3 +140,39 @@ type imageImageSource struct { func (i imageImageSource) image() (image.Image, error) { return i.img, nil } + +func parseHexColor(s string) (color.Color, error) { + // Remove leading hash if present + if len(s) > 0 && s[0] == '#' { + s = s[1:] + } + + // Parse based on length + var r, g, b, a uint8 + switch len(s) { + case 6: + // RGB format + var rgb uint32 + if _, err := fmt.Sscanf(s, "%06x", &rgb); err != nil { + return nil, fmt.Errorf("invalid hex color format: %w", err) + } + r = uint8((rgb >> 16) & 0xFF) + g = uint8((rgb >> 8) & 0xFF) + b = uint8(rgb & 0xFF) + a = 0xFF + case 8: + // RGBA format + var rgba uint32 + if _, err := fmt.Sscanf(s, "%08x", &rgba); err != nil { + return nil, fmt.Errorf("invalid hex color format: %w", err) + } + r = uint8((rgba >> 24) & 0xFF) + g = uint8((rgba >> 16) & 0xFF) + b = uint8((rgba >> 8) & 0xFF) + a = uint8(rgba & 0xFF) + default: + return nil, fmt.Errorf("invalid hex color length: expected 6 or 8 characters, got %d", len(s)) + } + + return color.RGBA{R: r, G: g, B: b, A: a}, nil +} diff --git a/services/imgedit/service.go b/services/imgedit/service.go index d9f3ba4..c53a37e 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -146,6 +146,35 @@ func (s *Service) DeleteProcessor(ctx context.Context, sessionID, processorID st return session, nil } +type UpdateProcessorReq struct { + ID string `json:"id"` + Props json.RawMessage `json:"props"` +} + +func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req UpdateProcessorReq) (*models.ImageEditSession, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + for i, p := range session.Processors { + if p.ID == req.ID { + session.Processors[i].Props = req.Props + break + } + } + + session.RecalcVersionIDs() + if err := s.sessionStore.save(session); err != nil { + return nil, err + } + if _, err := s.reprocess(ctx, session); err != nil { + return nil, err + } + + return session, nil +} + func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { site, user, err := s.fetchSiteAndUser(ctx) if err != nil { diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 5c8cc2d..21b41bb 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -1,12 +1,12 @@ -
    +
    -
    +
    - {{ .upload.Upload.Alt }} + {{ .upload.Upload.Alt }}
    @@ -25,6 +25,7 @@
    - Actions go here + +
    \ No newline at end of file From c8a276b248902c41af64e254c04ef8ed8fc954ba Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 28 Mar 2026 21:42:35 +1100 Subject: [PATCH 56/62] Have got saving working --- assets/js/controllers/edit_upload.js | 46 +++++++++++- cmds/server.go | 1 + handlers/imageedit.go | 21 ++++++ providers/db/gen/sqlgen/categories.sql.go | 2 +- providers/db/gen/sqlgen/db.go | 2 +- providers/db/gen/sqlgen/models.go | 2 +- providers/db/gen/sqlgen/pages.sql.go | 2 +- .../db/gen/sqlgen/pending_uploads.sql.go | 2 +- providers/db/gen/sqlgen/posts.sql.go | 2 +- providers/db/gen/sqlgen/pubtargets.sql.go | 2 +- providers/db/gen/sqlgen/sites.sql.go | 2 +- providers/db/gen/sqlgen/uploads.sql.go | 26 +++++-- providers/db/gen/sqlgen/users.sql.go | 2 +- providers/db/uploads.go | 13 +++- providers/uploadfiles/provider.go | 5 ++ services/imgedit/service.go | 52 ++++++++++++++ services/imgedit/store.go | 4 ++ services/uploads/manage.go | 72 +++++++++++++++++++ sql/queries/uploads.sql | 5 +- views/uploads/edit.html | 2 +- views/uploads/show.html | 5 +- 21 files changed, 248 insertions(+), 22 deletions(-) diff --git a/assets/js/controllers/edit_upload.js b/assets/js/controllers/edit_upload.js index f575bea..95cbb1e 100644 --- a/assets/js/controllers/edit_upload.js +++ b/assets/js/controllers/edit_upload.js @@ -1,3 +1,4 @@ +import feather from "feather-icons/dist/feather.js"; import Handlebars from "handlebars"; import {Controller} from "@hotwired/stimulus"; @@ -7,12 +8,12 @@ Handlebars.registerHelper("submit_on", function (id, event) { const processorFrame = Handlebars.compile(`
    -
    +
    {{name}} - X + >
    {{{props}}}
    @@ -78,6 +79,16 @@ export default class UploadEditController extends Controller { await this._removeProcessor(id); } + async saveUpload(ev) { + ev.preventDefault(); + await this._save("replace"); + } + + async saveNewUpload(ev) { + ev.preventDefault(); + await this._save("copy"); + } + async updateProcessor(ev) { ev.preventDefault(); let id = ev.params.id; @@ -108,6 +119,8 @@ export default class UploadEditController extends Controller { }); el.innerHTML += cardOuter; } + + feather.replace(); } async _createSession() { @@ -179,6 +192,33 @@ export default class UploadEditController extends Controller { }) } + async _save(mode) { + if (!this._state || !this._state.session) { + return; + } + + try { + let resp = await fetch(`/sites/${this.siteIdValue}/imageedit/${this._state.session.guid}/save`, { + method: 'POST', + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ mode }) + }); + + if (!resp.ok) { + console.error("Save failed:", resp.statusText); + return; + } + + let result = await resp.json(); + window.location.href = `/sites/${this.siteIdValue}/uploads/${result.upload_id}`; + } catch (e) { + console.error(e); + } + } + async _doReturningState(fn) { try { this._state = await fn(); diff --git a/cmds/server.go b/cmds/server.go index 7a1445a..28e2ccc 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -156,6 +156,7 @@ Starting weiro without any arguments will start the server. siteGroup.Patch("/imageedit/:sessionID", ieh.PatchSession) siteGroup.Post("/imageedit/:sessionID/processors", ieh.AddProcessor) siteGroup.Delete("/imageedit/:sessionID/processors/:processorID", ieh.DeleteProcessor) + siteGroup.Post("/imageedit/:sessionID/save", ieh.Save) siteGroup.Get("/imageedit/:sessionID/preview/:versionID", ieh.Preview) siteGroup.Get("/settings", ssh.General) diff --git a/handlers/imageedit.go b/handlers/imageedit.go index ced7f75..27a01b0 100644 --- a/handlers/imageedit.go +++ b/handlers/imageedit.go @@ -114,6 +114,27 @@ func (ieh ImageEditHandlers) DeleteProcessor(c fiber.Ctx) error { }) } +func (ieh ImageEditHandlers) Save(c fiber.Ctx) error { + sessionID := c.Params("sessionID") + if sessionID == "" { + return fiber.ErrBadRequest + } + + var req struct { + Mode string `json:"mode"` + } + if err := c.Bind().JSON(&req); err != nil { + return fiber.ErrBadRequest + } + + result, err := ieh.ImageEditService.Save(c.Context(), sessionID, req.Mode) + if err != nil { + return err + } + + return c.Status(http.StatusOK).JSON(result) +} + func (ieh ImageEditHandlers) PatchSession(c fiber.Ctx) error { var req struct { UpdateProc *imgedit.UpdateProcessorReq `json:"processor"` diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go index d5bc40d..95a26e5 100644 --- a/providers/db/gen/sqlgen/categories.sql.go +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: categories.sql package sqlgen diff --git a/providers/db/gen/sqlgen/db.go b/providers/db/gen/sqlgen/db.go index 8eab959..7d9d9e7 100644 --- a/providers/db/gen/sqlgen/db.go +++ b/providers/db/gen/sqlgen/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 package sqlgen diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 3df1193..348c1ab 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 package sqlgen diff --git a/providers/db/gen/sqlgen/pages.sql.go b/providers/db/gen/sqlgen/pages.sql.go index 1d53291..7dd5105 100644 --- a/providers/db/gen/sqlgen/pages.sql.go +++ b/providers/db/gen/sqlgen/pages.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pages.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pending_uploads.sql.go b/providers/db/gen/sqlgen/pending_uploads.sql.go index 63eeb60..a831bbe 100644 --- a/providers/db/gen/sqlgen/pending_uploads.sql.go +++ b/providers/db/gen/sqlgen/pending_uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pending_uploads.sql package sqlgen diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index ef3d170..129a49a 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: posts.sql package sqlgen diff --git a/providers/db/gen/sqlgen/pubtargets.sql.go b/providers/db/gen/sqlgen/pubtargets.sql.go index 69c09df..cd5cfa6 100644 --- a/providers/db/gen/sqlgen/pubtargets.sql.go +++ b/providers/db/gen/sqlgen/pubtargets.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: pubtargets.sql package sqlgen diff --git a/providers/db/gen/sqlgen/sites.sql.go b/providers/db/gen/sqlgen/sites.sql.go index 80ccbc0..797eaad 100644 --- a/providers/db/gen/sqlgen/sites.sql.go +++ b/providers/db/gen/sqlgen/sites.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: sites.sql package sqlgen diff --git a/providers/db/gen/sqlgen/uploads.sql.go b/providers/db/gen/sqlgen/uploads.sql.go index 189de2d..7ad3828 100644 --- a/providers/db/gen/sqlgen/uploads.sql.go +++ b/providers/db/gen/sqlgen/uploads.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: uploads.sql package sqlgen @@ -18,7 +18,7 @@ func (q *Queries) DeleteUpload(ctx context.Context, id int64) error { return err } -const insertUpload = `-- name: InsertUpload :exec +const insertUpload = `-- name: InsertUpload :one INSERT INTO uploads ( site_id, guid, @@ -43,8 +43,8 @@ type InsertUploadParams struct { CreatedAt int64 } -func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) error { - _, err := q.db.ExecContext(ctx, insertUpload, +func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) (int64, error) { + row := q.db.QueryRowContext(ctx, insertUpload, arg.SiteID, arg.Guid, arg.MimeType, @@ -54,7 +54,9 @@ func (q *Queries) InsertUpload(ctx context.Context, arg InsertUploadParams) erro arg.Alt, arg.CreatedAt, ) - return err + var id int64 + err := row.Scan(&id) + return id, err } const selectUploadByID = `-- name: SelectUploadByID :one @@ -154,3 +156,17 @@ func (q *Queries) UpdateUpload(ctx context.Context, arg UpdateUploadParams) erro _, err := q.db.ExecContext(ctx, updateUpload, arg.Alt, arg.ID) return err } + +const updateUploadFileSize = `-- name: UpdateUploadFileSize :exec +UPDATE uploads SET file_size = ? WHERE id = ? +` + +type UpdateUploadFileSizeParams struct { + FileSize int64 + ID int64 +} + +func (q *Queries) UpdateUploadFileSize(ctx context.Context, arg UpdateUploadFileSizeParams) error { + _, err := q.db.ExecContext(ctx, updateUploadFileSize, arg.FileSize, arg.ID) + return err +} diff --git a/providers/db/gen/sqlgen/users.sql.go b/providers/db/gen/sqlgen/users.sql.go index 6007589..a70a3bf 100644 --- a/providers/db/gen/sqlgen/users.sql.go +++ b/providers/db/gen/sqlgen/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.28.0 +// sqlc v1.30.0 // source: users.sql package sqlgen diff --git a/providers/db/uploads.go b/providers/db/uploads.go index 006b7cc..b3033ab 100644 --- a/providers/db/uploads.go +++ b/providers/db/uploads.go @@ -44,7 +44,7 @@ func (db *Provider) SelectUploadBySiteIDAndSlug(ctx context.Context, siteID int6 func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error { if upload.ID == 0 { - if err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{ + newID, err := db.queries.InsertUpload(ctx, sqlgen.InsertUploadParams{ SiteID: upload.SiteID, Guid: upload.GUID, MimeType: upload.MIMEType, @@ -53,9 +53,11 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error Slug: upload.Slug, Alt: upload.Alt, CreatedAt: upload.CreatedAt.Unix(), - }); err != nil { + }) + if err != nil { return err } + upload.ID = newID return nil } @@ -65,6 +67,13 @@ func (db *Provider) SaveUpload(ctx context.Context, upload *models.Upload) error }) } +func (db *Provider) UpdateUploadFileSize(ctx context.Context, id int64, fileSize int64) error { + return db.queries.UpdateUploadFileSize(ctx, sqlgen.UpdateUploadFileSizeParams{ + FileSize: fileSize, + ID: id, + }) +} + func (db *Provider) DeleteUpload(ctx context.Context, id int64) error { return db.queries.DeleteUpload(ctx, id) } diff --git a/providers/uploadfiles/provider.go b/providers/uploadfiles/provider.go index 2eb84e4..610a6f9 100644 --- a/providers/uploadfiles/provider.go +++ b/providers/uploadfiles/provider.go @@ -66,6 +66,11 @@ func copyFile(src, dst string) error { return err } +func (p *Provider) ReplaceFile(site models.Site, up models.Upload, srcPath string) error { + fullPath := p.uploadFileName(site, up) + return copyFile(srcPath, fullPath) +} + func (p *Provider) OpenUpload(site models.Site, up models.Upload) (io.ReadCloser, error) { fullPath := p.uploadFileName(site, up) return os.Open(fullPath) diff --git a/services/imgedit/service.go b/services/imgedit/service.go index c53a37e..926633c 100644 --- a/services/imgedit/service.go +++ b/services/imgedit/service.go @@ -175,6 +175,58 @@ func (s *Service) UpdateProcessor(ctx context.Context, sessionID string, req Upd return session, nil } +type SaveResult struct { + UploadID int64 `json:"upload_id"` +} + +func (s *Service) Save(ctx context.Context, sessionID string, mode string) (*SaveResult, error) { + session, err := s.loadAndVerifySession(ctx, sessionID) + if err != nil { + return nil, err + } + + if len(session.Processors) == 0 { + return nil, fmt.Errorf("no processors in session") + } + + lastProc := session.Processors[len(session.Processors)-1] + finalImagePath := fmt.Sprintf("%v/%v/%v.%v", s.scratchDir, session.GUID, lastProc.VersionID, session.ImageExt) + + var mimeType string + switch session.ImageExt { + case "jpg", "jpeg": + mimeType = "image/jpeg" + case "png": + mimeType = "image/png" + } + + var uploadID int64 + switch mode { + case "replace": + upload, err := s.uploadService.ReplaceUploadFile(ctx, session.BaseUploadID, finalImagePath) + if err != nil { + return nil, err + } + uploadID = upload.ID + case "copy": + baseUpload, _, err := s.uploadService.OpenUpload(ctx, session.BaseUploadID) + if err != nil { + return nil, err + } + upload, err := s.uploadService.CreateUploadFromFile(ctx, finalImagePath, baseUpload.Filename, mimeType) + if err != nil { + return nil, err + } + uploadID = upload.ID + default: + return nil, fmt.Errorf("unknown save mode: %v", mode) + } + + s.sessionStore.delete(session.GUID) + + return &SaveResult{UploadID: uploadID}, nil +} + func (s *Service) loadAndVerifySession(ctx context.Context, sessionID string) (*models.ImageEditSession, error) { site, user, err := s.fetchSiteAndUser(ctx) if err != nil { diff --git a/services/imgedit/store.go b/services/imgedit/store.go index 7638dbe..df3403a 100644 --- a/services/imgedit/store.go +++ b/services/imgedit/store.go @@ -42,6 +42,10 @@ func (ss *sessionStore) get(guid string) (*models.ImageEditSession, error) { return &sessionData, nil } +func (ss *sessionStore) delete(guid string) { + os.RemoveAll(filepath.Join(ss.baseDir, guid)) +} + func (ss *sessionStore) getImage(session *models.ImageEditSession, imageFilename string) (string, func() (io.ReadCloser, error), error) { fullPath := filepath.Join(ss.baseDir, session.GUID, imageFilename) if s, err := os.Stat(fullPath); err != nil { diff --git a/services/uploads/manage.go b/services/uploads/manage.go index 32debac..9cb24ea 100644 --- a/services/uploads/manage.go +++ b/services/uploads/manage.go @@ -6,7 +6,10 @@ import ( "html/template" "io" "log" + "os" + "path/filepath" "strings" + "time" "lmika.dev/lmika/weiro/models" ) @@ -67,6 +70,75 @@ func (s *Service) renderCopyTemplate(upload models.Upload) string { return sb.String() } +func (s *Service) ReplaceUploadFile(ctx context.Context, uploadID int64, srcPath string) (models.Upload, error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, err + } + + upload, err := s.db.SelectUploadByID(ctx, uploadID) + if err != nil { + return models.Upload{}, err + } else if upload.SiteID != site.ID { + return models.Upload{}, models.NotFoundError + } + + if err := s.up.ReplaceFile(site, upload, srcPath); err != nil { + return models.Upload{}, err + } + + stat, err := os.Stat(srcPath) + if err != nil { + return models.Upload{}, err + } + upload.FileSize = stat.Size() + + if err := s.db.UpdateUploadFileSize(ctx, upload.ID, upload.FileSize); err != nil { + return models.Upload{}, err + } + + return upload, nil +} + +func (s *Service) CreateUploadFromFile(ctx context.Context, srcPath string, filename string, mimeType string) (models.Upload, error) { + site, _, err := s.fetchSiteAndUser(ctx) + if err != nil { + return models.Upload{}, err + } + + stat, err := os.Stat(srcPath) + if err != nil { + return models.Upload{}, err + } + + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(filename), + ) + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: stat.Size(), + MIMEType: mimeType, + Filename: filename, + CreatedAt: newTime, + Slug: newSlug, + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + return models.Upload{}, err + } + + if err := s.up.AdoptFile(site, newUpload, srcPath); err != nil { + return models.Upload{}, err + } + + return newUpload, nil +} + func (s *Service) ListUploads(ctx context.Context) (res []UploadWithURL, _ error) { site, _, err := s.fetchSiteAndUser(ctx) if err != nil { diff --git a/sql/queries/uploads.sql b/sql/queries/uploads.sql index fc8b82d..f661591 100644 --- a/sql/queries/uploads.sql +++ b/sql/queries/uploads.sql @@ -7,7 +7,7 @@ SELECT * FROM uploads WHERE id = ? LIMIT 1; -- name: SelectUploadBySiteIDAndSlug :one SELECT * FROM uploads WHERE site_id = ? AND slug = ? LIMIT 1; --- name: InsertUpload :exec +-- name: InsertUpload :one INSERT INTO uploads ( site_id, guid, @@ -23,5 +23,8 @@ RETURNING id; -- name: UpdateUpload :exec UPDATE uploads SET alt = ? WHERE id = ?; +-- name: UpdateUploadFileSize :exec +UPDATE uploads SET file_size = ? WHERE id = ?; + -- name: DeleteUpload :exec DELETE FROM uploads WHERE id = ?; \ No newline at end of file diff --git a/views/uploads/edit.html b/views/uploads/edit.html index 21b41bb..a7b27ab 100644 --- a/views/uploads/edit.html +++ b/views/uploads/edit.html @@ -24,7 +24,7 @@
    -
    +
    diff --git a/views/uploads/show.html b/views/uploads/show.html index 087c10f..7b42a38 100644 --- a/views/uploads/show.html +++ b/views/uploads/show.html @@ -5,7 +5,10 @@ data-show-upload-site-id-value="{{ .upload.Upload.SiteID }}" data-show-upload-upload-id-value="{{ .upload.Upload.ID }}"> - + + Edit + +
    From 98828a48498259d48cbc1a6faff70ab8809cd73f Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sat, 28 Mar 2026 21:45:54 +1100 Subject: [PATCH 57/62] Removed some unused code --- services/imgedit/processing.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/services/imgedit/processing.go b/services/imgedit/processing.go index c1f99bf..ec84199 100644 --- a/services/imgedit/processing.go +++ b/services/imgedit/processing.go @@ -6,7 +6,6 @@ import ( "fmt" "image" "image/color" - "log" "os" "path/filepath" @@ -80,8 +79,6 @@ func (s *Service) reprocess(ctx context.Context, session *models.ImageEditSessio img = imageImageSource{resImg} } - log.Printf("result of processed image: %T", img) - return img, nil } @@ -105,10 +102,6 @@ func (s *Service) processImage(ctx context.Context, srcImg image.Image, processo defer f.Close() return imaging.Decode(f) - //case "shadow": - // shadow := makeBoxShadow(srcImg, color.Black, 4, 10, 0) - // composit := imaging.OverlayCenter(shadow, srcImg, 1.0) - // return composit, nil } proc, ok := processors[processor.Type] From 023574aac6a039e65329b52a283c002b426c2549 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 09:33:24 +1100 Subject: [PATCH 58/62] Removed the login challenge --- handlers/login.go | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/handlers/login.go b/handlers/login.go index 30ed0b4..34c1e96 100644 --- a/handlers/login.go +++ b/handlers/login.go @@ -37,9 +37,8 @@ func (lh *LoginHandler) Logout(c fiber.Ctx) error { func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { var req struct { - Username string `form:"username"` - Password string `form:"password"` - LoginChallenge string `form:"_login_challenge"` + Username string `form:"username"` + Password string `form:"password"` } if err := c.Bind().Body(&req); err != nil { return c.Status(fiber.StatusBadRequest).SendString("Failed to parse request body") @@ -51,11 +50,6 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error { sess := session.FromContext(c) - challenge, _ := sess.Get("_login_challenge").(string) - if challenge != req.LoginChallenge { - return c.Redirect().To("/login") - } - user, err := lh.AuthService.Login(c.Context(), req.Username, req.Password) if err != nil { return c.Status(fiber.StatusInternalServerError).SendString("Failed to login") From 9b20665d11b039d07f5142c393610e44cd25079b Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 10:45:45 +1100 Subject: [PATCH 59/62] Added support for footnotes and fixed category AJAX post --- assets/js/controllers/postedit.js | 10 ++++++++++ providers/markdown/renderer.go | 4 ++-- providers/sitebuilder/builder.go | 1 + providers/sitebuilder/processors.go | 5 +++++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/assets/js/controllers/postedit.js b/assets/js/controllers/postedit.js index 71328e3..f800c44 100644 --- a/assets/js/controllers/postedit.js +++ b/assets/js/controllers/postedit.js @@ -60,6 +60,16 @@ export default class PosteditController extends Controller { try { const formData = new FormData(this.element); let data = Object.fromEntries(formData.entries()); + + // Special handling for categories + let categoryIDs = []; + for (let i of formData.entries()) { + if (i[0] === "category_ids") { + categoryIDs.push(parseInt(i[1])) + } + } + + data["category_ids"] = categoryIDs; data = {...data, action: action || 'save'}; const response = await fetch(this.element.getAttribute("action"), { diff --git a/providers/markdown/renderer.go b/providers/markdown/renderer.go index aedd184..828ba96 100644 --- a/providers/markdown/renderer.go +++ b/providers/markdown/renderer.go @@ -22,7 +22,7 @@ type Renderer struct { func NewRendererForUI() *Renderer { mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), + goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithRendererOptions( gm_html.WithUnsafe(), ), @@ -48,7 +48,7 @@ func NewRendererForUI() *Renderer { func NewRendererForSite() *Renderer { mdParser := goldmark.New( - goldmark.WithExtensions(extension.GFM), + goldmark.WithExtensions(extension.GFM, extension.Footnote), goldmark.WithParserOptions( parser.WithAutoHeadingID(), ), diff --git a/providers/sitebuilder/builder.go b/providers/sitebuilder/builder.go index 71ce926..d0bf17b 100644 --- a/providers/sitebuilder/builder.go +++ b/providers/sitebuilder/builder.go @@ -49,6 +49,7 @@ func New(site pubmodel.Site, opts Options) (*Builder, error) { mdRenderer: markdown.NewRendererForSite(), postMDProcessors: []postMDProcessor{ uploadAbsoluteURL, + removeFootnoteHRs, }, }, nil } diff --git a/providers/sitebuilder/processors.go b/providers/sitebuilder/processors.go index c699160..605d077 100644 --- a/providers/sitebuilder/processors.go +++ b/providers/sitebuilder/processors.go @@ -35,3 +35,8 @@ func uploadAbsoluteURL(site pubmodel.Site, dom *goquery.Document) error { }) return nil } + +func removeFootnoteHRs(site pubmodel.Site, dom *goquery.Document) error { + dom.Find("div.footnotes > hr").Remove() + return nil +} From deca23b5995a3540b5d2bc489197641c2153c2b3 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 12:26:05 +1100 Subject: [PATCH 60/62] Fixed ordering of published posts --- providers/db/categories.go | 2 +- providers/db/gen/sqlgen/categories.sql.go | 8 ++-- providers/db/gen/sqlgen/posts.sql.go | 48 +++++++++++++++++++++++ providers/db/posts.go | 17 ++++++++ services/publisher/iter.go | 6 +-- services/publisher/service.go | 2 +- sql/queries/categories.sql | 2 +- sql/queries/posts.sql | 6 +++ 8 files changed, 81 insertions(+), 10 deletions(-) diff --git a/providers/db/categories.go b/providers/db/categories.go index 72fac94..f8db6d3 100644 --- a/providers/db/categories.go +++ b/providers/db/categories.go @@ -82,7 +82,7 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([ return cats, nil } -func (db *Provider) SelectPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { +func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ CategoryID: categoryID, Limit: pp.Limit, diff --git a/providers/db/gen/sqlgen/categories.sql.go b/providers/db/gen/sqlgen/categories.sql.go index 95a26e5..f6a291f 100644 --- a/providers/db/gen/sqlgen/categories.sql.go +++ b/providers/db/gen/sqlgen/categories.sql.go @@ -227,7 +227,7 @@ func (q *Queries) SelectCategoryBySlugAndSite(ctx context.Context, arg SelectCat return i, err } -const selectPostsOfCategory = `-- name: SelectPostsOfCategory :many +const selectPublishedPostsOfCategory = `-- name: SelectPublishedPostsOfCategory :many SELECT p.id, p.site_id, p.state, p.guid, p.title, p.body, p.slug, p.created_at, p.updated_at, p.published_at, p.deleted_at 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 @@ -235,14 +235,14 @@ ORDER BY p.published_at DESC LIMIT ? OFFSET ? ` -type SelectPostsOfCategoryParams struct { +type SelectPublishedPostsOfCategoryParams struct { CategoryID int64 Limit int64 Offset int64 } -func (q *Queries) SelectPostsOfCategory(ctx context.Context, arg SelectPostsOfCategoryParams) ([]Post, error) { - rows, err := q.db.QueryContext(ctx, selectPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) +func (q *Queries) SelectPublishedPostsOfCategory(ctx context.Context, arg SelectPublishedPostsOfCategoryParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfCategory, arg.CategoryID, arg.Limit, arg.Offset) if err != nil { return nil, err } diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index 129a49a..b1d3afb 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -200,6 +200,54 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSitePa return items, nil } +const selectPublishedPostsOfSite = `-- name: SelectPublishedPostsOfSite :many +SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at +FROM posts +WHERE site_id = ?1 AND state = 0 AND deleted_at = 0 +ORDER BY published_at DESC LIMIT ?3 OFFSET ?2 +` + +type SelectPublishedPostsOfSiteParams struct { + SiteID int64 + Offset int64 + Limit int64 +} + +func (q *Queries) SelectPublishedPostsOfSite(ctx context.Context, arg SelectPublishedPostsOfSiteParams) ([]Post, error) { + rows, err := q.db.QueryContext(ctx, selectPublishedPostsOfSite, arg.SiteID, arg.Offset, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Post + for rows.Next() { + var i Post + if err := rows.Scan( + &i.ID, + &i.SiteID, + &i.State, + &i.Guid, + &i.Title, + &i.Body, + &i.Slug, + &i.CreatedAt, + &i.UpdatedAt, + &i.PublishedAt, + &i.DeletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const softDeletePost = `-- name: SoftDeletePost :exec UPDATE posts SET deleted_at = ? WHERE id = ? ` diff --git a/providers/db/posts.go b/providers/db/posts.go index 7f58d1a..3b86aaf 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -47,6 +47,23 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDel return posts, nil } +func (db *Provider) SelectPublishedPostsOfSite(ctx context.Context, siteID int64, pp PagingParams) ([]*models.Post, error) { + rows, err := db.queries.SelectPublishedPostsOfSite(ctx, sqlgen.SelectPublishedPostsOfSiteParams{ + SiteID: siteID, + 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) SelectPost(ctx context.Context, postID int64) (*models.Post, error) { row, err := db.queries.SelectPost(ctx, postID) if err != nil { diff --git a/services/publisher/iter.go b/services/publisher/iter.go index ea70616..d07d4fe 100644 --- a/services/publisher/iter.go +++ b/services/publisher/iter.go @@ -9,10 +9,10 @@ import ( ) // postIter returns a post iterator which returns posts in reverse chronological order. -func (s *Publisher) postIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { +func (s *Publisher) publishedPostIter(ctx context.Context, site int64) iter.Seq[models.Maybe[*models.Post]] { return func(yield func(models.Maybe[*models.Post]) bool) { paging := db.PagingParams{Offset: 0, Limit: 50} - page, err := s.db.SelectPostsOfSite(ctx, site, false, paging) + page, err := s.db.SelectPublishedPostsOfSite(ctx, site, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return @@ -45,7 +45,7 @@ func (s *Publisher) postIterByCategory(ctx context.Context, categoryID int64) it 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) + page, err := s.db.SelectPublishedPostsOfCategory(ctx, categoryID, paging) if err != nil { yield(models.Maybe[*models.Post]{Err: err}) return diff --git a/services/publisher/service.go b/services/publisher/service.go index adfcdd7..a5072a5 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -79,7 +79,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error { pubSite := pubmodel.Site{ Site: site, PostIter: func(ctx context.Context) iter.Seq[models.Maybe[*models.Post]] { - return p.postIter(ctx, site.ID) + return p.publishedPostIter(ctx, site.ID) }, BaseURL: target.BaseURL, Uploads: uploads, diff --git a/sql/queries/categories.sql b/sql/queries/categories.sql index 4b48506..b8e0e64 100644 --- a/sql/queries/categories.sql +++ b/sql/queries/categories.sql @@ -17,7 +17,7 @@ INNER JOIN post_categories pc ON pc.category_id = c.id WHERE pc.post_id = ? ORDER BY c.name ASC; --- name: SelectPostsOfCategory :many +-- name: SelectPublishedPostsOfCategory :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 diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 5a4c18e..feaae7f 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -17,6 +17,12 @@ WHERE site_id = sqlc.arg(site_id) AND ( END ) ORDER BY created_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); +-- name: SelectPublishedPostsOfSite :many +SELECT * +FROM posts +WHERE site_id = sqlc.arg(site_id) AND state = 0 AND deleted_at = 0 +ORDER BY published_at DESC LIMIT sqlc.arg(limit) OFFSET sqlc.arg(offset); + -- name: SelectPost :one SELECT * FROM posts WHERE id = ? LIMIT 1; From d21aeadd5655adc431f5effa780e46909c28cd40 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Sun, 29 Mar 2026 20:29:42 +1100 Subject: [PATCH 61/62] Fixed build --- providers/db/categories.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/providers/db/categories.go b/providers/db/categories.go index f8db6d3..23a9e67 100644 --- a/providers/db/categories.go +++ b/providers/db/categories.go @@ -83,7 +83,7 @@ func (db *Provider) SelectCategoriesOfPost(ctx context.Context, postID int64) ([ } func (db *Provider) SelectPublishedPostsOfCategory(ctx context.Context, categoryID int64, pp PagingParams) ([]*models.Post, error) { - rows, err := db.queries.SelectPostsOfCategory(ctx, sqlgen.SelectPostsOfCategoryParams{ + rows, err := db.queries.SelectPublishedPostsOfCategory(ctx, sqlgen.SelectPublishedPostsOfCategoryParams{ CategoryID: categoryID, Limit: pp.Limit, Offset: pp.Offset, From a3197f9b11f4802d7f6fc5e34223d4a0678cea3d Mon Sep 17 00:00:00 2001 From: lmika Date: Thu, 9 Apr 2026 11:40:52 +0000 Subject: [PATCH 62/62] Add Obsidian vault import feature (#8) - New 'Import Obsidian' action on site settings page - Upload a zip file of an Obsidian vault to import all notes as posts - Markdown notes imported with title from filename, published date from file timestamp, and body with front-matter stripped - Images and other attachments saved as Upload records - New obsimport service handles zip traversal and import logic - Unit tests for front-matter stripping Co-authored-by: Shelley Co-authored-by: exe.dev user Reviewed-on: https://lmika.dev/lmika/weiro/pulls/8 --- cmds/server.go | 4 + handlers/obsimport.go | 50 +++++++ services/obsimport/service.go | 229 +++++++++++++++++++++++++++++ services/obsimport/service_test.go | 51 +++++++ services/services.go | 4 + views/obsimport/form.html | 21 +++ views/obsimport/result.html | 10 ++ views/sitesettings/general.html | 7 + 8 files changed, 376 insertions(+) create mode 100644 handlers/obsimport.go create mode 100644 services/obsimport/service.go create mode 100644 services/obsimport/service_test.go create mode 100644 views/obsimport/form.html create mode 100644 views/obsimport/result.html diff --git a/cmds/server.go b/cmds/server.go index 28e2ccc..29a8c2a 100644 --- a/cmds/server.go +++ b/cmds/server.go @@ -115,6 +115,7 @@ Starting weiro without any arguments will start the server. ssh := handlers.SiteSettingsHandler{SiteService: svcs.Sites} ch := handlers.CategoriesHandler{CategoryService: svcs.Categories} pgh := handlers.PagesHandler{PageService: svcs.Pages} + oih := handlers.ObsImportHandler{ObsImportService: svcs.ObsImport, ScratchDir: cfg.ScratchDir} app.Get("/login", lh.Login) app.Post("/login", lh.DoLogin) @@ -162,6 +163,9 @@ Starting weiro without any arguments will start the server. siteGroup.Get("/settings", ssh.General) siteGroup.Post("/settings", ssh.UpdateGeneral) + siteGroup.Get("/import/obsidian", oih.Form) + siteGroup.Post("/import/obsidian", oih.Upload) + siteGroup.Get("/categories", ch.Index) siteGroup.Get("/categories/new", ch.New) siteGroup.Get("/categories/:categoryID", ch.Edit) diff --git a/handlers/obsimport.go b/handlers/obsimport.go new file mode 100644 index 0000000..e20be77 --- /dev/null +++ b/handlers/obsimport.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/gofiber/fiber/v3" + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/services/obsimport" +) + +type ObsImportHandler struct { + ObsImportService *obsimport.Service + ScratchDir string +} + +func (h ObsImportHandler) Form(c fiber.Ctx) error { + return c.Render("obsimport/form", fiber.Map{}) +} + +func (h ObsImportHandler) Upload(c fiber.Ctx) error { + site := c.Locals("site").(models.Site) + + fileHeader, err := c.FormFile("zipfile") + if err != nil { + return fiber.NewError(fiber.StatusBadRequest, "no file provided") + } + + // Save uploaded file to scratch dir + if err := os.MkdirAll(h.ScratchDir, 0755); err != nil { + return err + } + + dstPath := filepath.Join(h.ScratchDir, models.NewNanoID()+".zip") + if err := c.SaveFile(fileHeader, dstPath); err != nil { + return err + } + defer os.Remove(dstPath) + + result, err := h.ObsImportService.ImportZip(c.Context(), dstPath) + if err != nil { + return err + } + + return c.Render("obsimport/result", fiber.Map{ + "result": result, + "siteURL": fmt.Sprintf("/sites/%v/posts", site.ID), + }) +} diff --git a/services/obsimport/service.go b/services/obsimport/service.go new file mode 100644 index 0000000..0852031 --- /dev/null +++ b/services/obsimport/service.go @@ -0,0 +1,229 @@ +package obsimport + +import ( + "archive/zip" + "bufio" + "context" + "fmt" + "io" + "log" + "mime" + "os" + "path/filepath" + "strings" + "time" + + "lmika.dev/lmika/weiro/models" + "lmika.dev/lmika/weiro/providers/db" + "lmika.dev/lmika/weiro/providers/uploadfiles" + "lmika.dev/lmika/weiro/services/publisher" +) + +type Service struct { + db *db.Provider + up *uploadfiles.Provider + publisher *publisher.Queue + scratchDir string +} + +func New(db *db.Provider, up *uploadfiles.Provider, publisher *publisher.Queue, scratchDir string) *Service { + return &Service{ + db: db, + up: up, + publisher: publisher, + scratchDir: scratchDir, + } +} + +type ImportResult struct { + PostsImported int + UploadsImported int +} + +func (s *Service) ImportZip(ctx context.Context, zipPath string) (ImportResult, error) { + site, ok := models.GetSite(ctx) + if !ok { + return ImportResult{}, models.SiteRequiredError + } + + zr, err := zip.OpenReader(zipPath) + if err != nil { + return ImportResult{}, fmt.Errorf("open zip: %w", err) + } + defer zr.Close() + + var result ImportResult + + for _, f := range zr.File { + if f.FileInfo().IsDir() { + continue + } + + ext := strings.ToLower(filepath.Ext(f.Name)) + if ext == ".md" || ext == ".markdown" { + if err := s.importNote(ctx, site, f); err != nil { + log.Printf("warn: skipping note %s: %v", f.Name, err) + continue + } + result.PostsImported++ + } else if isAttachment(ext) { + if err := s.importAttachment(ctx, site, f); err != nil { + log.Printf("warn: skipping attachment %s: %v", f.Name, err) + continue + } + result.UploadsImported++ + } + } + + s.publisher.Queue(site) + + return result, nil +} + +func (s *Service) importNote(ctx context.Context, site models.Site, f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + data, err := io.ReadAll(rc) + if err != nil { + return err + } + + body := stripFrontMatter(string(data)) + title := strings.TrimSuffix(filepath.Base(f.Name), filepath.Ext(f.Name)) + publishedAt := f.Modified + if publishedAt.IsZero() { + publishedAt = time.Now() + } + + renderTZ, err := time.LoadLocation(site.Timezone) + if err != nil { + renderTZ = time.UTC + } + publishedAt = publishedAt.In(renderTZ) + + post := &models.Post{ + SiteID: site.ID, + GUID: models.NewNanoID(), + State: models.StatePublished, + Title: title, + Body: body, + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PublishedAt: publishedAt, + } + post.Slug = post.BestSlug() + + return s.db.SavePost(ctx, post) +} + +func (s *Service) importAttachment(ctx context.Context, site models.Site, f *zip.File) error { + rc, err := f.Open() + if err != nil { + return err + } + defer rc.Close() + + // Write to a temp file in scratch dir + if err := os.MkdirAll(s.scratchDir, 0755); err != nil { + return err + } + + tmpFile, err := os.CreateTemp(s.scratchDir, "obsimport-*"+filepath.Ext(f.Name)) + if err != nil { + return err + } + tmpPath := tmpFile.Name() + + if _, err := io.Copy(tmpFile, rc); err != nil { + tmpFile.Close() + os.Remove(tmpPath) + return err + } + tmpFile.Close() + + filename := filepath.Base(f.Name) + mimeType := mime.TypeByExtension(filepath.Ext(filename)) + if mimeType == "" { + mimeType = "application/octet-stream" + } + + stat, err := os.Stat(tmpPath) + if err != nil { + os.Remove(tmpPath) + return err + } + + newUploadGUID := models.NewNanoID() + newTime := time.Now().UTC() + newSlug := filepath.Join( + fmt.Sprintf("%04d", newTime.Year()), + fmt.Sprintf("%02d", newTime.Month()), + newUploadGUID+filepath.Ext(filename), + ) + + newUpload := models.Upload{ + SiteID: site.ID, + GUID: models.NewNanoID(), + FileSize: stat.Size(), + MIMEType: mimeType, + Filename: filename, + CreatedAt: newTime, + Slug: newSlug, + } + if err := s.db.SaveUpload(ctx, &newUpload); err != nil { + os.Remove(tmpPath) + return err + } + + if err := s.up.AdoptFile(site, newUpload, tmpPath); err != nil { + os.Remove(tmpPath) + return err + } + + return nil +} + +// stripFrontMatter removes YAML front matter (delimited by ---) from markdown content. +func stripFrontMatter(content string) string { + scanner := bufio.NewScanner(strings.NewReader(content)) + + // Check if the first line is a front matter delimiter + if !scanner.Scan() { + return content + } + firstLine := strings.TrimSpace(scanner.Text()) + if firstLine != "---" { + return content + } + + // Skip until the closing --- + for scanner.Scan() { + if strings.TrimSpace(scanner.Text()) == "---" { + // Return everything after the closing delimiter + var rest strings.Builder + for scanner.Scan() { + rest.WriteString(scanner.Text()) + rest.WriteString("\n") + } + return strings.TrimLeft(rest.String(), "\n") + } + } + + // No closing delimiter found, return original content + return content +} + +var attachmentExts = map[string]bool{ + ".png": true, ".jpg": true, ".jpeg": true, ".gif": true, ".svg": true, ".webp": true, + ".bmp": true, ".ico": true, ".tiff": true, ".tif": true, + ".mp3": true, ".mp4": true, ".wav": true, ".ogg": true, ".webm": true, + ".pdf": true, ".doc": true, ".docx": true, ".xls": true, ".xlsx": true, +} + +func isAttachment(ext string) bool { + return attachmentExts[ext] +} diff --git a/services/obsimport/service_test.go b/services/obsimport/service_test.go new file mode 100644 index 0000000..51123de --- /dev/null +++ b/services/obsimport/service_test.go @@ -0,0 +1,51 @@ +package obsimport + +import "testing" + +func TestStripFrontMatter(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "no front matter", + input: "Hello world\nThis is a note", + want: "Hello world\nThis is a note", + }, + { + name: "with front matter", + input: "---\ntitle: Test\ntags: [a, b]\n---\nHello world\nThis is a note\n", + want: "Hello world\nThis is a note\n", + }, + { + name: "only front matter", + input: "---\ntitle: Test\n---\n", + want: "", + }, + { + name: "unclosed front matter", + input: "---\ntitle: Test\nno closing delimiter", + want: "---\ntitle: Test\nno closing delimiter", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "front matter with leading newlines stripped", + input: "---\nkey: val\n---\n\n\nBody here\n", + want: "Body here\n", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := stripFrontMatter(tt.input) + if got != tt.want { + t.Errorf("stripFrontMatter() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/services/services.go b/services/services.go index ab1a4ca..a79e903 100644 --- a/services/services.go +++ b/services/services.go @@ -9,6 +9,7 @@ import ( "lmika.dev/lmika/weiro/services/auth" "lmika.dev/lmika/weiro/services/categories" "lmika.dev/lmika/weiro/services/imgedit" + "lmika.dev/lmika/weiro/services/obsimport" "lmika.dev/lmika/weiro/services/pages" "lmika.dev/lmika/weiro/services/posts" "lmika.dev/lmika/weiro/services/publisher" @@ -27,6 +28,7 @@ type Services struct { ImageEdit *imgedit.Service Categories *categories.Service Pages *pages.Service + ObsImport *obsimport.Service } func New(cfg config.Config) (*Services, error) { @@ -46,6 +48,7 @@ func New(cfg config.Config) (*Services, error) { imageEditService := imgedit.New(uploadService, filepath.Join(cfg.ScratchDir, "imageedit")) categoriesService := categories.New(dbp, publisherQueue) pagesService := pages.New(dbp, publisherQueue) + obsImportService := obsimport.New(dbp, ufp, publisherQueue, filepath.Join(cfg.ScratchDir, "obsimport")) return &Services{ DB: dbp, @@ -58,6 +61,7 @@ func New(cfg config.Config) (*Services, error) { ImageEdit: imageEditService, Categories: categoriesService, Pages: pagesService, + ObsImport: obsImportService, }, nil } diff --git a/views/obsimport/form.html b/views/obsimport/form.html new file mode 100644 index 0000000..ccb27a5 --- /dev/null +++ b/views/obsimport/form.html @@ -0,0 +1,21 @@ +
    +
    +
    Import from Obsidian
    +

    Select an Obsidian vault exported as a Zip file. All Markdown notes will be imported as posts, and any images or attachments will be imported as uploads.

    +
    +
    + +
    + +
    +
    +
    +
    +
    + + Cancel +
    +
    +
    +
    +
    diff --git a/views/obsimport/result.html b/views/obsimport/result.html new file mode 100644 index 0000000..15ebe31 --- /dev/null +++ b/views/obsimport/result.html @@ -0,0 +1,10 @@ +
    +
    +
    Import Complete
    +
    +

    Successfully imported {{ .result.PostsImported }} post(s) and {{ .result.UploadsImported }} upload(s).

    +
    + Go to Posts + Back to Settings +
    +
    diff --git a/views/sitesettings/general.html b/views/sitesettings/general.html index 6f1833b..c0989c5 100644 --- a/views/sitesettings/general.html +++ b/views/sitesettings/general.html @@ -66,5 +66,12 @@
    +
    +
    +
    + Import Obsidian + Import posts and attachments from an Obsidian vault zip file. +
    +
    \ No newline at end of file