Have got post creation working.
This commit is contained in:
parent
63b19a249a
commit
8e0ffb6c24
|
@ -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
83
gen/sqlc/dbq/posts.sql.go
Normal 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
2
go.mod
|
@ -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
4
go.sum
|
@ -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
14
handlers/ctx.go
Normal 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
49
handlers/post.go
Normal 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))
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
9
main.go
9
main.go
|
@ -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
20
models/posts.go
Normal 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
|
||||
}
|
|
@ -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
46
providers/db/posts.go
Normal 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
|
||||
}
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
|
|
47
services/posts/services.go
Normal file
47
services/posts/services.go
Normal 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))
|
||||
}
|
|
@ -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
14
sql/queries/posts.sql
Normal 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;
|
|
@ -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)
|
||||
);
|
|
@ -1 +0,0 @@
|
|||
<h1>Site {{.site.Title}}</h1>
|
15
templates/sites/posts.html
Normal file
15
templates/sites/posts.html
Normal 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}}
|
Loading…
Reference in a new issue