Have got post creation working.

This commit is contained in:
Leon Mika 2025-01-27 14:23:54 +11:00
parent 63b19a249a
commit 8e0ffb6c24
20 changed files with 479 additions and 11 deletions

View file

@ -4,6 +4,66 @@
package dbq
import (
"database/sql/driver"
"fmt"
"github.com/jackc/pgx/v5/pgtype"
)
type PostState string
const (
PostStateDraft PostState = "draft"
PostStatePublished PostState = "published"
)
func (e *PostState) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = PostState(s)
case string:
*e = PostState(s)
default:
return fmt.Errorf("unsupported scan type for PostState: %T", src)
}
return nil
}
type NullPostState struct {
PostState PostState
Valid bool // Valid is true if PostState is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullPostState) Scan(value interface{}) error {
if value == nil {
ns.PostState, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.PostState.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullPostState) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.PostState), nil
}
type Post struct {
ID int64
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
}
type Site struct {
ID int64
Name string

83
gen/sqlc/dbq/posts.sql.go Normal file
View file

@ -0,0 +1,83 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: posts.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const insertPost = `-- name: InsertPost :one
INSERT INTO post (
site_id,
title,
body,
state,
props,
post_date,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`
type InsertPostParams struct {
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
}
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
row := q.db.QueryRow(ctx, insertPost,
arg.SiteID,
arg.Title,
arg.Body,
arg.State,
arg.Props,
arg.PostDate,
arg.CreatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const listPosts = `-- name: ListPosts :many
SELECT id, site_id, title, body, state, props, post_date, created_at FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25
`
func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
rows, err := q.db.Query(ctx, listPosts, siteID)
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.Title,
&i.Body,
&i.State,
&i.Props,
&i.PostDate,
&i.CreatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

2
go.mod
View file

@ -41,4 +41,6 @@ require (
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 // indirect
)

4
go.sum
View file

@ -81,3 +81,7 @@ golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 h1:Seqp9vlIw3uJBL0V/eWIM3dAnSuToJ/cztkRQtl3g20=
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=

14
handlers/ctx.go Normal file
View file

@ -0,0 +1,14 @@
package handlers
import (
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-crm/models"
)
type siteKeyType struct{}
var siteKey siteKeyType
func GetSite(c *fiber.Ctx) models.Site {
return c.UserContext().Value(siteKey).(models.Site)
}

49
handlers/post.go Normal file
View file

@ -0,0 +1,49 @@
package handlers
import (
"fmt"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-crm/services/posts"
)
type Post struct {
Post *posts.Service
}
func (h *Post) Posts() fiber.Handler {
return func(c *fiber.Ctx) error {
site := GetSite(c)
posts, err := h.Post.ListPostOfSite(c.UserContext(), site)
if err != nil {
return err
}
return c.Render("sites/posts", fiber.Map{
"site": site,
"posts": posts,
}, "layouts/main")
}
}
func (h *Post) Create() fiber.Handler {
type Req struct {
Body string `json:"body" form:"body"`
}
return func(c *fiber.Ctx) error {
site := GetSite(c)
var req Req
if err := c.BodyParser(&req); err != nil {
return err
}
_, err := h.Post.Create(c.UserContext(), site, req.Body)
if err != nil {
return err
}
return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID))
}
}

View file

@ -1,6 +1,7 @@
package handlers
import (
"context"
"fmt"
"github.com/gofiber/fiber/v2"
"lmika.dev/lmika/hugo-crm/services/sites"
@ -30,9 +31,29 @@ func (s *Site) Show() fiber.Handler {
}
site, err := s.Site.GetSite(c.UserContext(), id)
if err != nil {
return err
}
return c.Render("sites/index", fiber.Map{
"site": site,
}, "layouts/main")
}
}
func (s *Site) WithSite() fiber.Handler {
return func(c *fiber.Ctx) error {
id, err := c.ParamsInt("siteId")
if err != nil {
return err
}
site, err := s.Site.GetSite(c.UserContext(), id)
if err != nil {
return err
}
c.SetUserContext(context.WithValue(c.UserContext(), siteKey, site))
return c.Next()
}
}

View file

@ -11,6 +11,7 @@ import (
"lmika.dev/lmika/hugo-crm/providers/hugo"
"lmika.dev/lmika/hugo-crm/providers/themes"
"lmika.dev/lmika/hugo-crm/services/jobs"
"lmika.dev/lmika/hugo-crm/services/posts"
"lmika.dev/lmika/hugo-crm/services/sitebuilder"
"lmika.dev/lmika/hugo-crm/services/sites"
"lmika.dev/lmika/hugo-crm/templates"
@ -41,8 +42,10 @@ func main() {
siteBuilderService := sitebuilder.New(themesProvider, gitProvider, hugoProvider)
siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService)
postService := posts.New(dbp, siteBuilderService, jobService)
siteHandlers := handlers.Site{Site: siteService}
postHandlers := handlers.Post{Post: postService}
log.Println("Connected to database")
if err := dbp.Migrate(context.Background()); err != nil {
@ -63,6 +66,12 @@ func main() {
app.Post("/sites", siteHandlers.Create())
app.Get("/sites/:siteId", siteHandlers.Show())
siteGroup := app.Group("/sites/:siteId")
siteGroup.Use(siteHandlers.WithSite())
siteGroup.Get("/posts", postHandlers.Posts())
siteGroup.Post("/posts", postHandlers.Create())
jobService.Start()
defer jobService.Stop()

20
models/posts.go Normal file
View file

@ -0,0 +1,20 @@
package models
import "time"
type PostState string
const (
PostStateDraft PostState = "draft"
PostStatePublished PostState = "published"
)
type Post struct {
ID int64
SiteID int64
Title string
Body string
State PostState
PostDate time.Time
CreatedAt time.Time
}

View file

@ -3,4 +3,10 @@ package models
type ThemeMeta struct {
Name string `json:"name"`
URL string `json:"repo"`
// Indicates that this theme prefers posts have titles.
PreferTitle bool
// Content directory for "blog" posts
PostDir string `json:"post_dir"`
}

46
providers/db/posts.go Normal file
View file

@ -0,0 +1,46 @@
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"lmika.dev/lmika/hugo-crm/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-crm/models"
"lmika.dev/pkg/modash/moslice"
)
func (db *DB) ListPostsOfSite(ctx context.Context, siteID int64) ([]models.Post, error) {
res, err := db.q.ListPosts(ctx, siteID)
if err != nil {
return nil, err
}
return moslice.Map(res, func(p dbq.Post) models.Post {
return models.Post{
ID: p.ID,
SiteID: p.SiteID,
Title: p.Title.String,
Body: p.Body,
State: models.PostState(p.State),
PostDate: p.PostDate.Time,
CreatedAt: p.CreatedAt.Time,
}
}), nil
}
func (db *DB) InsertPost(ctx context.Context, p *models.Post) error {
res, err := db.q.InsertPost(ctx, dbq.InsertPostParams{
SiteID: p.SiteID,
Title: pgtype.Text{String: p.Title, Valid: p.Title != ""},
Body: p.Body,
State: dbq.PostState(p.State),
Props: []byte(`{}`),
PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()},
CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()},
})
if err != nil {
return err
}
p.ID = res
return nil
}

View file

@ -3,6 +3,7 @@ package hugo
type SiteDir string
const (
BaseSiteDir SiteDir = "base"
ThemeSiteDir SiteDir = "theme"
BaseSiteDir SiteDir = "base"
ThemeSiteDir SiteDir = "theme"
ContentSiteDir SiteDir = "content"
)

View file

@ -36,7 +36,7 @@ func (p *Provider) SiteStagingDir(site models.Site, what SiteDir) string {
case ThemeSiteDir:
return filepath.Join(p.stagingDir, site.Name, "themes", site.Theme)
}
return ""
return filepath.Join(p.stagingDir, site.Name, string(what))
}
func (p *Provider) NewSite(ctx context.Context, site models.Site) error {

View file

@ -4,7 +4,9 @@ import "lmika.dev/lmika/hugo-crm/models"
var themes = map[string]models.ThemeMeta{
"bear": models.ThemeMeta{
Name: "bear",
URL: "https://github.com/janraasch/hugo-bearblog",
Name: "bear",
URL: "https://github.com/janraasch/hugo-bearblog",
PreferTitle: true,
PostDir: "blog",
},
}

View file

@ -0,0 +1,47 @@
package posts
import (
"context"
"lmika.dev/lmika/hugo-crm/models"
"lmika.dev/lmika/hugo-crm/providers/db"
"lmika.dev/lmika/hugo-crm/services/jobs"
"lmika.dev/lmika/hugo-crm/services/sitebuilder"
"time"
)
type Service struct {
db *db.DB
sb *sitebuilder.Service
jobs *jobs.Service
}
func New(
db *db.DB,
sb *sitebuilder.Service,
jobs *jobs.Service,
) *Service {
return &Service{
db: db,
sb: sb,
jobs: jobs,
}
}
func (s *Service) ListPostOfSite(ctx context.Context, site models.Site) ([]models.Post, error) {
return s.db.ListPostsOfSite(ctx, site.ID)
}
func (s *Service) Create(ctx context.Context, site models.Site, body string) (models.Post, error) {
post := models.Post{
SiteID: site.ID,
Body: body,
State: models.PostStatePublished,
PostDate: time.Now(),
CreatedAt: time.Now(),
}
if err := s.db.InsertPost(ctx, &post); err != nil {
return models.Post{}, err
}
return post, s.jobs.Queue(ctx, s.sb.WritePost(site, post))
}

View file

@ -3,11 +3,15 @@ package sitebuilder
import (
"context"
"errors"
"gopkg.in/yaml.v3"
"lmika.dev/lmika/hugo-crm/models"
"lmika.dev/lmika/hugo-crm/providers/git"
"lmika.dev/lmika/hugo-crm/providers/hugo"
"lmika.dev/lmika/hugo-crm/providers/themes"
"log"
"os"
"path/filepath"
"time"
)
type Service struct {
@ -55,3 +59,57 @@ func (s *Service) CreateNewSite(site models.Site) models.Job {
},
}
}
func (s *Service) WritePost(site models.Site, post models.Post) models.Job {
return models.Job{
Do: func(ctx context.Context) error {
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
return errors.New("theme not found")
}
postFilename := filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md"))
log.Printf(" .. post %v", postFilename)
if err := os.MkdirAll(filepath.Dir(postFilename), 0755); err != nil {
return err
}
frontMatter := map[string]string{
"date": post.PostDate.Format(time.RFC3339),
}
if post.Title != "" {
frontMatter["title"] = post.Title
} else if themeMeta.PreferTitle {
frontMatter["title"] = post.PostDate.Format(time.ANSIC)
}
fmBytes, err := yaml.Marshal(frontMatter)
if err != nil {
return err
}
f, err := os.Create(postFilename)
if err != nil {
return err
}
defer f.Close()
if _, err := f.WriteString("---\n"); err != nil {
return err
}
if _, err := f.Write(fmBytes); err != nil {
return err
}
if _, err := f.WriteString("---\n"); err != nil {
return err
}
if _, err := f.WriteString(post.Body); err != nil {
return err
}
return nil
},
}
}

14
sql/queries/posts.sql Normal file
View file

@ -0,0 +1,14 @@
-- name: ListPosts :many
SELECT * FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25;
-- name: InsertPost :one
INSERT INTO post (
site_id,
title,
body,
state,
props,
post_date,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id;

View file

@ -1,8 +1,26 @@
CREATE TYPE post_state AS ENUM (
'draft',
'published'
);
CREATE TABLE site (
id BIGSERIAL NOT NULL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
url TEXT NOT NULL,
theme TEXT NOT NULL,
props JSON NOT NULL
name TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
url TEXT NOT NULL,
theme TEXT NOT NULL,
props JSON NOT NULL
);
CREATE TABLE post (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
title TEXT,
body TEXT NOT NULL,
state post_state NOT NULL,
props JSON NOT NULL,
post_date TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (site_id) REFERENCES site(id)
);

View file

@ -1 +0,0 @@
<h1>Site {{.site.Title}}</h1>

View file

@ -0,0 +1,15 @@
<h1>Site {{.site.Title}}</h1>
<form method="post" action="/sites/{{.site.ID}}/posts">
<textarea name="body"></textarea>
<input type="submit" value="Post">
</form>
{{range .posts}}
<div class="post">
{{.Body}}
</div>
<hr>
{{else}}
<p>No posts yet</p>
{{end}}