Started working on pages

This commit is contained in:
Leon Mika 2025-02-16 11:43:22 +11:00
parent e2f159e980
commit ba12398d2f
30 changed files with 1391 additions and 145 deletions

View file

@ -0,0 +1,81 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: bundles.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getBundleWithID = `-- name: GetBundleWithID :one
SELECT id, site_id, name, created_at, updated_at FROM bundles WHERE id = $1
`
func (q *Queries) GetBundleWithID(ctx context.Context, id int64) (Bundle, error) {
row := q.db.QueryRow(ctx, getBundleWithID, id)
var i Bundle
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertBundle = `-- name: InsertBundle :one
INSERT INTO bundles (
site_id,
name,
created_at,
updated_at
) VALUES ($1, $2, $3, $3) RETURNING id
`
type InsertBundleParams struct {
SiteID int64
Name string
CreatedAt pgtype.Timestamp
}
func (q *Queries) InsertBundle(ctx context.Context, arg InsertBundleParams) (int64, error) {
row := q.db.QueryRow(ctx, insertBundle, arg.SiteID, arg.Name, arg.CreatedAt)
var id int64
err := row.Scan(&id)
return id, err
}
const listBundles = `-- name: ListBundles :many
SELECT id, site_id, name, created_at, updated_at FROM bundles WHERE site_id = $1
`
func (q *Queries) ListBundles(ctx context.Context, siteID int64) ([]Bundle, error) {
rows, err := q.db.Query(ctx, listBundles, siteID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []Bundle
for rows.Next() {
var i Bundle
if err := rows.Scan(
&i.ID,
&i.SiteID,
&i.Name,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}

View file

@ -11,6 +11,49 @@ import (
"github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgtype"
) )
type PageNameProvenance string
const (
PageNameProvenanceUser PageNameProvenance = "user"
PageNameProvenanceTitle PageNameProvenance = "title"
PageNameProvenanceDate PageNameProvenance = "date"
)
func (e *PageNameProvenance) Scan(src interface{}) error {
switch s := src.(type) {
case []byte:
*e = PageNameProvenance(s)
case string:
*e = PageNameProvenance(s)
default:
return fmt.Errorf("unsupported scan type for PageNameProvenance: %T", src)
}
return nil
}
type NullPageNameProvenance struct {
PageNameProvenance PageNameProvenance
Valid bool // Valid is true if PageNameProvenance is not NULL
}
// Scan implements the Scanner interface.
func (ns *NullPageNameProvenance) Scan(value interface{}) error {
if value == nil {
ns.PageNameProvenance, ns.Valid = "", false
return nil
}
ns.Valid = true
return ns.PageNameProvenance.Scan(value)
}
// Value implements the driver Valuer interface.
func (ns NullPageNameProvenance) Value() (driver.Value, error) {
if !ns.Valid {
return nil, nil
}
return string(ns.PageNameProvenance), nil
}
type PostState string type PostState string
const ( const (
@ -135,15 +178,47 @@ func (ns NullTargetType) Value() (driver.Value, error) {
return string(ns.TargetType), nil return string(ns.TargetType), nil
} }
type Bundle struct {
ID int64
SiteID int64
Name string
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
type Page struct {
ID int64
SiteID int64
BundleID int64
Name string
NameProvenance PageNameProvenance
Title pgtype.Text
Role pgtype.Int8
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
type Post struct { type Post struct {
ID int64 ID int64
SiteID int64 SiteID int64
Title pgtype.Text Title pgtype.Text
Role pgtype.Int8
Body string Body string
State PostState State PostState
Props []byte Props []byte
PostDate pgtype.Timestamptz PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
type PostRole struct {
ID int64
SiteID int64
LayoutName string
} }
type PublishTarget struct { type PublishTarget struct {

233
gen/sqlc/dbq/pages.sql.go Normal file
View file

@ -0,0 +1,233 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.28.0
// source: pages.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const deletePageWithID = `-- name: DeletePageWithID :exec
DELETE FROM pages WHERE id = $1
`
func (q *Queries) DeletePageWithID(ctx context.Context, id int64) error {
_, err := q.db.Exec(ctx, deletePageWithID, id)
return err
}
const getPageWithID = `-- name: GetPageWithID :one
SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE id = $1
`
func (q *Queries) GetPageWithID(ctx context.Context, id int64) (Page, error) {
row := q.db.QueryRow(ctx, getPageWithID, id)
var i Page
err := row.Scan(
&i.ID,
&i.SiteID,
&i.BundleID,
&i.Name,
&i.NameProvenance,
&i.Title,
&i.Role,
&i.Body,
&i.State,
&i.Props,
&i.PublishDate,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
const insertPage = `-- name: InsertPage :one
INSERT INTO pages (
site_id,
bundle_id,
name,
name_provenance,
title,
role,
body,
state,
props,
publish_date,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
RETURNING id
`
type InsertPageParams struct {
SiteID int64
BundleID int64
Name string
NameProvenance PageNameProvenance
Title pgtype.Text
Role pgtype.Int8
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
}
func (q *Queries) InsertPage(ctx context.Context, arg InsertPageParams) (int64, error) {
row := q.db.QueryRow(ctx, insertPage,
arg.SiteID,
arg.BundleID,
arg.Name,
arg.NameProvenance,
arg.Title,
arg.Role,
arg.Body,
arg.State,
arg.Props,
arg.PublishDate,
arg.CreatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const listPages = `-- name: ListPages :many
SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25
`
func (q *Queries) ListPages(ctx context.Context, siteID int64) ([]Page, error) {
rows, err := q.db.Query(ctx, listPages, 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.BundleID,
&i.Name,
&i.NameProvenance,
&i.Title,
&i.Role,
&i.Body,
&i.State,
&i.Props,
&i.PublishDate,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const listPublishablePages = `-- name: ListPublishablePages :many
SELECT id, site_id, bundle_id, name, name_provenance, title, role, body, state, props, publish_date, created_at, updated_at
FROM pages
WHERE id > $1 AND site_id = $2 AND state = 'published'
ORDER BY id LIMIT 100
`
type ListPublishablePagesParams struct {
ID int64
SiteID int64
}
func (q *Queries) ListPublishablePages(ctx context.Context, arg ListPublishablePagesParams) ([]Page, error) {
rows, err := q.db.Query(ctx, listPublishablePages, arg.ID, arg.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.BundleID,
&i.Name,
&i.NameProvenance,
&i.Title,
&i.Role,
&i.Body,
&i.State,
&i.Props,
&i.PublishDate,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const updatePage = `-- name: UpdatePage :exec
UPDATE pages SET
site_id = $2,
bundle_id = $3,
name = $4,
name_provenance = $5,
title = $6,
role = $7,
body = $8,
state = $9,
props = $10,
publish_date = $11,
created_at = $12,
updated_at = $13
WHERE id = $1
`
type UpdatePageParams struct {
ID int64
SiteID int64
BundleID int64
Name string
NameProvenance PageNameProvenance
Title pgtype.Text
Role pgtype.Int8
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
func (q *Queries) UpdatePage(ctx context.Context, arg UpdatePageParams) error {
_, err := q.db.Exec(ctx, updatePage,
arg.ID,
arg.SiteID,
arg.BundleID,
arg.Name,
arg.NameProvenance,
arg.Title,
arg.Role,
arg.Body,
arg.State,
arg.Props,
arg.PublishDate,
arg.CreatedAt,
arg.UpdatedAt,
)
return err
}

View file

@ -21,7 +21,7 @@ func (q *Queries) DeletePost(ctx context.Context, id int64) error {
} }
const getPostWithID = `-- name: GetPostWithID :one const getPostWithID = `-- name: GetPostWithID :one
SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE id = $1 LIMIT 1 SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE id = $1 LIMIT 1
` `
func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
@ -31,11 +31,13 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.Title, &i.Title,
&i.Role,
&i.Body, &i.Body,
&i.State, &i.State,
&i.Props, &i.Props,
&i.PostDate, &i.PublishDate,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
) )
return i, err return i, err
} }
@ -47,9 +49,10 @@ INSERT INTO posts (
body, body,
state, state,
props, props,
post_date, publish_date,
created_at created_at,
) VALUES ($1, $2, $3, $4, $5, $6, $7) updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id RETURNING id
` `
@ -59,8 +62,9 @@ type InsertPostParams struct {
Body string Body string
State PostState State PostState
Props []byte Props []byte
PostDate pgtype.Timestamptz PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
} }
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) { func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
@ -70,8 +74,9 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
arg.Body, arg.Body,
arg.State, arg.State,
arg.Props, arg.Props,
arg.PostDate, arg.PublishDate,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt,
) )
var id int64 var id int64
err := row.Scan(&id) err := row.Scan(&id)
@ -79,7 +84,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
} }
const listPosts = `-- name: ListPosts :many const listPosts = `-- name: ListPosts :many
SELECT id, site_id, title, body, state, props, post_date, created_at FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25 SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25
` `
func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) { func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
@ -95,11 +100,13 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.Title, &i.Title,
&i.Role,
&i.Body, &i.Body,
&i.State, &i.State,
&i.Props, &i.Props,
&i.PostDate, &i.PublishDate,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -112,20 +119,20 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
} }
const listPublishablePosts = `-- name: ListPublishablePosts :many const listPublishablePosts = `-- name: ListPublishablePosts :many
SELECT id, site_id, title, body, state, props, post_date, created_at SELECT id, site_id, title, role, body, state, props, publish_date, created_at, updated_at
FROM posts FROM posts
WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3
ORDER BY id LIMIT 100 ORDER BY id LIMIT 100
` `
type ListPublishablePostsParams struct { type ListPublishablePostsParams struct {
ID int64 ID int64
SiteID int64 SiteID int64
PostDate pgtype.Timestamptz PublishDate pgtype.Timestamptz
} }
func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishablePostsParams) ([]Post, error) { func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishablePostsParams) ([]Post, error) {
rows, err := q.db.Query(ctx, listPublishablePosts, arg.ID, arg.SiteID, arg.PostDate) rows, err := q.db.Query(ctx, listPublishablePosts, arg.ID, arg.SiteID, arg.PublishDate)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -137,11 +144,13 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.Title, &i.Title,
&i.Role,
&i.Body, &i.Body,
&i.State, &i.State,
&i.Props, &i.Props,
&i.PostDate, &i.PublishDate,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -160,8 +169,8 @@ UPDATE posts SET
body = $4, body = $4,
state = $5, state = $5,
props = $6, props = $6,
post_date = $7 publish_date = $7,
-- updated_at = $7 updated_at = $8
WHERE id = $1 WHERE id = $1
` `
@ -172,7 +181,8 @@ type UpdatePostParams struct {
Body string Body string
State PostState State PostState
Props []byte Props []byte
PostDate pgtype.Timestamptz PublishDate pgtype.Timestamptz
UpdatedAt pgtype.Timestamp
} }
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
@ -183,7 +193,8 @@ func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
arg.Body, arg.Body,
arg.State, arg.State,
arg.Props, arg.Props,
arg.PostDate, arg.PublishDate,
arg.UpdatedAt,
) )
return err return err
} }

2
go.mod
View file

@ -48,5 +48,5 @@ require (
golang.org/x/sys v0.29.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.21.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac // indirect lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d // indirect
) )

2
go.sum
View file

@ -99,3 +99,5 @@ lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1 h1:Seqp9vlIw3uJBL0V/eWIM
lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= lmika.dev/pkg/modash v0.0.0-20250127022145-5dcbffe270a1/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=
lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac h1:i/C+DYDCVQTQHtv7w1O8m20RMez6YS9fUIlhAGjTZhU= lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac h1:i/C+DYDCVQTQHtv7w1O8m20RMez6YS9fUIlhAGjTZhU=
lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI= lmika.dev/pkg/modash v0.0.0-20250201221851-97d4b9b4a1ac/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=
lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d h1:x5aMBOkCr4cjJyFmq+qJVUsByfffD9k56HYDx1yZSR4=
lmika.dev/pkg/modash v0.0.0-20250216001243-c73e50a0913d/go.mod h1:8NDl/yR1eCCEhip9FJlVuMNXIeaztQ0Ks/tizExFcTI=

123
handlers/page.go Normal file
View file

@ -0,0 +1,123 @@
package handlers
import (
"errors"
"fmt"
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/services/pages"
"net/http"
)
type Pages struct {
Svc *pages.Service
}
func (h *Pages) Index(c fiber.Ctx) error {
site := GetSite(c)
pages, err := h.Svc.ListPagesOfSite(c.Context(), site)
if err != nil {
return err
}
return c.Render("pages/index", fiber.Map{
"pages": pages,
}, "layouts/site")
}
func (h *Pages) New(c fiber.Ctx) error {
return c.Render("pages/edit", fiber.Map{
"page": models.Page{},
}, "layouts/site")
}
func (h *Pages) Create(c fiber.Ctx) error {
site := GetSite(c)
var req struct {
Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"`
}
if err := c.Bind().Body(&req); err != nil {
return err
}
_, err := h.Svc.Create(c.Context(), site, pages.NewPost{
Title: req.Title,
Body: req.Body,
})
if err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID))
}
func (h *Pages) Edit(c fiber.Ctx) error {
site := GetSite(c)
pageID := fiber.Params[int](c, "pageId")
if pageID == 0 {
return errors.New("pageId is required")
}
page, err := h.Svc.GetPage(c.Context(), pageID)
if err != nil {
return err
} else if page.SiteID != site.ID {
return fmt.Errorf("page id %v not equal to site id %v", pageID, site.ID)
}
return c.Render("pages/edit", fiber.Map{
"page": page,
}, "layouts/site")
}
func (h *Pages) Update(c fiber.Ctx) error {
site := GetSite(c)
pageID := fiber.Params[int](c, "pageId")
if pageID == 0 {
return errors.New("pageId is required")
}
var req struct {
Title string `json:"title" form:"title"`
Body string `json:"body" form:"body"`
}
if err := c.Bind().Body(&req); err != nil {
return err
}
if _, err := h.Svc.Update(c.Context(), site, int64(pageID), pages.NewPost{
Title: req.Title,
Body: req.Body,
}); err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID))
}
func (h *Pages) Delete(c fiber.Ctx) error {
site := GetSite(c)
pageID := fiber.Params[int](c, "pageId")
if pageID == 0 {
return errors.New("pageID is required")
}
if err := h.Svc.DeletePage(c.Context(), site, pageID); err != nil {
return err
}
return Select(c,
HTMX(func(c fiber.Ctx) error {
return c.Status(http.StatusOK).SendString("")
}),
Otherwise(func(c fiber.Ctx) error {
return c.Redirect().To(fmt.Sprintf("/sites/%v/pages", site.ID))
}),
)
}

10
main.go
View file

@ -21,6 +21,7 @@ import (
"lmika.dev/lmika/hugo-cms/providers/netlify" "lmika.dev/lmika/hugo-cms/providers/netlify"
"lmika.dev/lmika/hugo-cms/providers/themes" "lmika.dev/lmika/hugo-cms/providers/themes"
"lmika.dev/lmika/hugo-cms/services/jobs" "lmika.dev/lmika/hugo-cms/services/jobs"
"lmika.dev/lmika/hugo-cms/services/pages"
"lmika.dev/lmika/hugo-cms/services/posts" "lmika.dev/lmika/hugo-cms/services/posts"
"lmika.dev/lmika/hugo-cms/services/sitebuilder" "lmika.dev/lmika/hugo-cms/services/sitebuilder"
"lmika.dev/lmika/hugo-cms/services/sites" "lmika.dev/lmika/hugo-cms/services/sites"
@ -85,10 +86,12 @@ func main() {
siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService) siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService)
postService := posts.New(dbp, siteBuilderService, jobService) postService := posts.New(dbp, siteBuilderService, jobService)
pageService := pages.New(dbp, siteBuilderService, jobService)
indexHandlers := handlers.IndexHandler{} indexHandlers := handlers.IndexHandler{}
siteHandlers := handlers.Site{Site: siteService, Bus: bus} siteHandlers := handlers.Site{Site: siteService, Bus: bus}
postHandlers := handlers.Post{Post: postService} postHandlers := handlers.Post{Post: postService}
pageHandlers := handlers.Pages{Svc: pageService}
authHandlers := handlers.AuthHandler{UserService: userService} authHandlers := handlers.AuthHandler{UserService: userService}
tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html") tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html")
@ -137,6 +140,13 @@ func main() {
sr.Post("/posts/:postId", postHandlers.Update) sr.Post("/posts/:postId", postHandlers.Update)
sr.Delete("/posts/:postId", postHandlers.Delete) sr.Delete("/posts/:postId", postHandlers.Delete)
sr.Get("/pages", pageHandlers.Index)
sr.Get("/pages/new", pageHandlers.New)
sr.Post("/pages", pageHandlers.Create)
sr.Get("/pages/:pageId", pageHandlers.Edit)
sr.Post("/pages/:pageId", pageHandlers.Update)
sr.Delete("/pages/:pageId", pageHandlers.Delete)
sr.Get("/settings", siteHandlers.Settings) sr.Get("/settings", siteHandlers.Settings)
sr.Post("/settings", siteHandlers.SaveSettings) sr.Post("/settings", siteHandlers.SaveSettings)
sr.Get("/sse", siteHandlers.SSE) sr.Get("/sse", siteHandlers.SSE)

40
models/bundle.go Normal file
View file

@ -0,0 +1,40 @@
package models
import "time"
const (
RootBundleName = "_root"
)
// NameProvenance encodes where the name came from, whether it was set by the user or autogenerated in some way
type NameProvenance int
const (
UserNameProvenance NameProvenance = iota
TitleNameProvenance NameProvenance = iota
DateNameProvenance NameProvenance = iota
)
type Bundle struct {
ID int64
SiteID int64
Name string
CreatedAt time.Time
UpdatedAt time.Time
}
type Page struct {
ID int64
SiteID int64
BundleID int64
Name string
NameProvenance NameProvenance
Title string
Role int64
Body string
State PostState
Props []byte
PublishDate time.Time
CreatedAt time.Time
UpdatedAt time.Time
}

View file

@ -16,7 +16,7 @@ type Post struct {
Title string Title string
Body string Body string
State PostState State PostState
PostDate time.Time PublishDate time.Time
CreatedAt time.Time CreatedAt time.Time
UpdatedAt time.Time UpdatedAt time.Time
} }

View file

@ -8,6 +8,6 @@ type ThemeMeta struct {
// Indicates that this theme prefers posts have titles. // Indicates that this theme prefers posts have titles.
PreferTitle bool PreferTitle bool
// Content directory for "blog" posts // Page bundle for "blog" posts
PostDir string `json:"post_dir"` BlogPostBundle string `json:"post_dir"`
} }

50
providers/db/bundles.go Normal file
View file

@ -0,0 +1,50 @@
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"lmika.dev/lmika/hugo-cms/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/pkg/modash/moslice"
)
func (db *DB) InsertBundle(ctx context.Context, bundle *models.Bundle) error {
id, err := db.q.InsertBundle(ctx, dbq.InsertBundleParams{
SiteID: bundle.SiteID,
Name: bundle.Name,
CreatedAt: pgtype.Timestamp{Time: bundle.CreatedAt, Valid: true},
})
if err != nil {
return err
}
bundle.ID = id
return nil
}
func (db *DB) ListBundles(ctx context.Context, siteID int64) ([]models.Bundle, error) {
res, err := db.q.ListBundles(ctx, siteID)
if err != nil {
return nil, err
}
return moslice.Map(res, dbBundleToBundle), nil
}
func (db *DB) GetBundleWithID(ctx context.Context, id int64) (models.Bundle, error) {
res, err := db.q.GetBundleWithID(ctx, id)
if err != nil {
return models.Bundle{}, err
}
return dbBundleToBundle(res), nil
}
func dbBundleToBundle(b dbq.Bundle) models.Bundle {
return models.Bundle{
ID: b.ID,
SiteID: b.SiteID,
Name: b.Name,
CreatedAt: b.CreatedAt.Time,
UpdatedAt: b.UpdatedAt.Time,
}
}

105
providers/db/page.go Normal file
View file

@ -0,0 +1,105 @@
package db
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
"lmika.dev/lmika/hugo-cms/gen/sqlc/dbq"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/pkg/modash/momap"
"lmika.dev/pkg/modash/moslice"
)
var nameProvenanceToDBNameProvenance = map[models.NameProvenance]dbq.PageNameProvenance{
models.UserNameProvenance: dbq.PageNameProvenanceUser,
models.TitleNameProvenance: dbq.PageNameProvenanceTitle,
models.DateNameProvenance: dbq.PageNameProvenanceDate,
}
var dbNameProvenanceToNameProvenance = momap.ReverseMap(nameProvenanceToDBNameProvenance)
func (db *DB) InsertPage(ctx context.Context, page *models.Page) error {
id, err := db.q.InsertPage(ctx, dbq.InsertPageParams{
SiteID: page.SiteID,
BundleID: page.BundleID,
Name: page.Name,
NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance],
Title: pgtype.Text{String: page.Title, Valid: page.Title != ""},
Body: page.Body,
State: dbq.PostState(page.State),
Props: []byte(`{}`),
PublishDate: pgtype.Timestamptz{Time: page.PublishDate, Valid: !page.PublishDate.IsZero()},
CreatedAt: pgtype.Timestamp{Time: page.CreatedAt, Valid: true},
})
if err != nil {
return err
}
page.ID = id
return nil
}
func (db *DB) UpdatePage(ctx context.Context, page *models.Page) error {
return db.q.UpdatePage(ctx, dbq.UpdatePageParams{
ID: page.ID,
SiteID: page.SiteID,
BundleID: page.BundleID,
Name: page.Name,
NameProvenance: nameProvenanceToDBNameProvenance[page.NameProvenance],
Title: pgtype.Text{String: page.Title, Valid: page.Title != ""},
Body: page.Body,
State: dbq.PostState(page.State),
Props: []byte(`{}`),
PublishDate: pgtype.Timestamptz{Time: page.PublishDate, Valid: true},
CreatedAt: pgtype.Timestamp{Time: page.CreatedAt, Valid: true},
UpdatedAt: pgtype.Timestamp{Time: page.UpdatedAt, Valid: true},
})
}
func (db *DB) ListPagesOfSite(ctx context.Context, siteID int64) ([]models.Page, error) {
res, err := db.q.ListPages(ctx, siteID)
if err != nil {
return nil, err
}
return moslice.Map(res, dbPageToPage), nil
}
func (db *DB) ListPublishablePages(ctx context.Context, fromID, siteID int64) ([]models.Page, error) {
res, err := db.q.ListPublishablePages(ctx, dbq.ListPublishablePagesParams{
ID: fromID,
SiteID: siteID,
})
if err != nil {
return nil, err
}
return moslice.Map(res, dbPageToPage), nil
}
func (db *DB) GetPage(ctx context.Context, postID int64) (models.Page, error) {
res, err := db.q.GetPageWithID(ctx, postID)
if err != nil {
return models.Page{}, err
}
return dbPageToPage(res), nil
}
func (db *DB) DeletePage(ctx context.Context, pageID int64) error {
return db.q.DeletePageWithID(ctx, pageID)
}
func dbPageToPage(p dbq.Page) models.Page {
return models.Page{
ID: p.ID,
SiteID: p.SiteID,
BundleID: p.BundleID,
Name: p.Name,
NameProvenance: dbNameProvenanceToNameProvenance[p.NameProvenance],
Title: p.Title.String,
Body: p.Body,
State: models.PostState(p.State),
PublishDate: p.PublishDate.Time,
CreatedAt: p.CreatedAt.Time,
}
}

View file

@ -35,7 +35,7 @@ func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, no
res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{ res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{
ID: fromID, ID: fromID,
SiteID: siteID, SiteID: siteID,
PostDate: pgtype.Timestamptz{Time: now, Valid: true}, PublishDate: pgtype.Timestamptz{Time: now, Valid: true},
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@ -51,8 +51,9 @@ func (db *DB) InsertPost(ctx context.Context, p *models.Post) error {
Body: p.Body, Body: p.Body,
State: dbq.PostState(p.State), State: dbq.PostState(p.State),
Props: []byte(`{}`), Props: []byte(`{}`),
PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()}, PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()},
CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()},
UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()},
}) })
if err != nil { if err != nil {
return err return err
@ -70,8 +71,8 @@ func (db *DB) UpdatePost(ctx context.Context, p *models.Post) error {
Body: p.Body, Body: p.Body,
State: dbq.PostState(p.State), State: dbq.PostState(p.State),
Props: []byte(`{}`), Props: []byte(`{}`),
PostDate: pgtype.Timestamptz{Time: p.PostDate, Valid: !p.PostDate.IsZero()}, PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()},
//CreatedAt: pgtype.Timestamp{Time: p.CreatedAt, Valid: !p.CreatedAt.IsZero()}, UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()},
}) })
} }
@ -82,7 +83,7 @@ func dbPostToPost(p dbq.Post) models.Post {
Title: p.Title.String, Title: p.Title.String,
Body: p.Body, Body: p.Body,
State: models.PostState(p.State), State: models.PostState(p.State),
PostDate: p.PostDate.Time, PublishDate: p.PublishDate.Time,
CreatedAt: p.CreatedAt.Time, CreatedAt: p.CreatedAt.Time,
} }
} }

View file

@ -8,20 +8,20 @@ var themes = []models.ThemeMeta{
Name: "Bear", Name: "Bear",
URL: "https://github.com/janraasch/hugo-bearblog", URL: "https://github.com/janraasch/hugo-bearblog",
PreferTitle: true, PreferTitle: true,
PostDir: "blog", BlogPostBundle: "blog",
}, },
{ {
ID: "terminal", ID: "terminal",
Name: "Terminal", Name: "Terminal",
URL: "https://github.com/panr/hugo-theme-terminal", URL: "https://github.com/panr/hugo-theme-terminal",
PreferTitle: true, PreferTitle: true,
PostDir: "posts", BlogPostBundle: "posts",
}, },
{ {
ID: "yingyang", ID: "yingyang",
Name: "Yingyang", Name: "Yingyang",
URL: "https://github.com/joway/hugo-theme-yinyang", URL: "https://github.com/joway/hugo-theme-yinyang",
PreferTitle: true, PreferTitle: true,
PostDir: "posts", BlogPostBundle: "posts",
}, },
} }

183
services/pages/services.go Normal file
View file

@ -0,0 +1,183 @@
package pages
import (
"context"
"errors"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/providers/db"
"lmika.dev/lmika/hugo-cms/services/jobs"
"lmika.dev/lmika/hugo-cms/services/sitebuilder"
"lmika.dev/pkg/modash/moslice"
"strings"
"time"
"unicode"
)
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) ListPagesOfSite(ctx context.Context, site models.Site) ([]models.Page, error) {
return s.db.ListPagesOfSite(ctx, site.ID)
}
func (s *Service) GetPage(ctx context.Context, id int) (models.Page, error) {
post, err := s.db.GetPage(ctx, int64(id))
if err != nil {
return models.Page{}, err
}
return post, nil
}
func (s *Service) DeletePage(ctx context.Context, site models.Site, id int) error {
post, err := s.db.GetPage(ctx, int64(id))
if err != nil {
return err
}
if err := s.db.DeletePage(ctx, int64(id)); err != nil {
return err
}
return s.jobs.Queue(ctx, s.sb.DeletePage(site, post))
}
func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Page, error) {
siteBundles, err := s.db.ListBundles(ctx, site.ID)
if err != nil {
return models.Page{}, err
} else if len(siteBundles) == 0 {
return models.Page{}, errors.New("no bundles found")
}
rootBundle, ok := moslice.FindWhere(siteBundles, func(t models.Bundle) bool {
return t.Name == models.RootBundleName
})
if !ok {
return models.Page{}, errors.New("root bundle not found")
}
publishTime := time.Now()
name := s.normalizePageName(req.Title)
nameProvenance := models.TitleNameProvenance
if name == "" {
// Use the timestamp as the name
name = publishTime.Format("2006-01-02-150405")
nameProvenance = models.DateNameProvenance
}
post := models.Page{
SiteID: site.ID,
BundleID: rootBundle.ID,
Name: s.normalizePageName(req.Title),
NameProvenance: nameProvenance,
Title: req.Title,
Body: req.Body,
State: models.PostStatePublished,
PublishDate: time.Now(),
}
if err := s.save(ctx, site, rootBundle, &post); err != nil {
return models.Page{}, err
}
return post, nil
}
func (s *Service) Update(ctx context.Context, site models.Site, pageID int64, req NewPost) (models.Page, error) {
page, err := s.db.GetPage(ctx, pageID)
if err != nil {
return models.Page{}, err
}
if page.SiteID != site.ID {
return models.Page{}, errors.New("page not found")
}
bundle, err := s.db.GetBundleWithID(ctx, page.BundleID)
if err != nil {
return models.Page{}, err
} else if bundle.SiteID != site.ID {
return models.Page{}, errors.New("page not found")
}
// Update the title if it wasn't set by the user
if page.NameProvenance != models.UserNameProvenance {
if req.Title == "" {
page.Name = page.PublishDate.Format("2006-01-02-150405")
page.NameProvenance = models.DateNameProvenance
} else {
page.Name = s.normalizePageName(req.Title)
page.NameProvenance = models.TitleNameProvenance
}
}
page.Title = req.Title
page.Body = req.Body
if err := s.save(ctx, site, bundle, &page); err != nil {
return models.Page{}, err
}
return page, nil
}
func (s *Service) save(ctx context.Context, site models.Site, bundle models.Bundle, page *models.Page) error {
page.SiteID = site.ID
if page.ID == 0 {
page.CreatedAt = time.Now()
page.UpdatedAt = time.Now()
if err := s.db.InsertPage(ctx, page); err != nil {
return err
}
} else {
page.UpdatedAt = time.Now()
if err := s.db.UpdatePage(ctx, page); err != nil {
return err
}
}
return s.jobs.Queue(ctx, s.sb.WritePage(site, bundle, *page))
}
func (s *Service) normalizePageName(title string) string {
var sb strings.Builder
lastSpace := false
for _, r := range title {
switch {
case unicode.IsSpace(r):
if !lastSpace {
sb.WriteRune('-')
lastSpace = true
}
case unicode.IsNumber(r):
lastSpace = false
sb.WriteRune(r)
case unicode.IsLetter(r):
lastSpace = false
sb.WriteRune(unicode.ToLower(r))
}
}
return sb.String()
}
type NewPost struct {
Title string
Body string
}

View file

@ -59,7 +59,7 @@ func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (mo
Title: req.Title, Title: req.Title,
Body: req.Body, Body: req.Body,
State: models.PostStatePublished, State: models.PostStatePublished,
PostDate: time.Now(), PublishDate: time.Now(),
} }
if err := s.Save(ctx, site, &post); err != nil { if err := s.Save(ctx, site, &post); err != nil {

View file

@ -0,0 +1,105 @@
package sitebuilder
import (
"context"
"fmt"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/pkg/modash/momap"
"os"
"time"
)
func (s *Service) WritePage(site models.Site, bundle models.Bundle, page models.Page) models.Job {
return models.Job{
Do: func(ctx context.Context) error {
s.signalSiteBuildingStarted(ctx, site)
defer s.signalSiteBuildingFinished(ctx, site)
rbn, err := s.fullRebuildNecessary(ctx, site)
if err != nil {
return err
} else if rbn {
return s.rebuildSite(ctx, site, site)
}
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
return fmt.Errorf("theme %s not found in themes", site.Theme)
}
if err := s.writePage(site, themeMeta, bundle, page); err != nil {
return err
}
return s.publish(ctx, site)
},
}
}
func (s *Service) DeletePage(site models.Site, page models.Page) models.Job {
return models.Job{
Do: func(ctx context.Context) error {
s.signalSiteBuildingStarted(ctx, site)
defer s.signalSiteBuildingFinished(ctx, site)
bundle, err := s.db.GetBundleWithID(ctx, page.BundleID)
if err != nil {
return err
}
postFilename := s.pageFilename(site, bundle, page)
if os.Remove(postFilename) != nil {
return nil
}
// TODO: if dir is empty, delete it
return s.publish(ctx, site)
},
}
}
func (s *Service) writeAllPages(ctx context.Context, site models.Site) error {
themeMeta, ok := s.themes.Lookup(site.Theme)
if !ok {
return fmt.Errorf("theme %s not found in themes", site.Theme)
}
bundles, err := s.db.ListBundles(ctx, site.ID)
if err != nil {
return err
}
bundlesByID := momap.FromSlice(bundles, func(b models.Bundle) (int64, models.Bundle) { return b.ID, b })
var startId int64
for {
pages, err := s.db.ListPublishablePages(ctx, int64(startId), site.ID)
if err != nil {
return err
} else if len(pages) == 0 {
return nil
}
for _, page := range pages {
if err := s.writePage(site, themeMeta, bundlesByID[page.BundleID], page); err != nil {
return err
}
}
startId = pages[len(pages)-1].ID
}
}
func (s *Service) writePage(site models.Site, themeMeta models.ThemeMeta, bundle models.Bundle, page models.Page) error {
postFilename := s.pageFilename(site, bundle, page)
frontMatter := map[string]any{
"date": page.PublishDate.Format(time.RFC3339),
}
if page.Title != "" {
frontMatter["title"] = page.Title
} else if themeMeta.PreferTitle {
frontMatter["title"] = page.PublishDate.Format(time.ANSIC)
}
return s.writeMarkdownFile(postFilename, frontMatter, page.Body)
}

View file

@ -6,7 +6,6 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
"lmika.dev/lmika/hugo-cms/models" "lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/providers/hugo" "lmika.dev/lmika/hugo-cms/providers/hugo"
"log"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@ -104,19 +103,21 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
postFilename := s.postFilename(site, themeMeta, post) postFilename := s.postFilename(site, themeMeta, post)
log.Printf(" .. post %v", postFilename) frontMatter := map[string]any{
"date": post.PublishDate.Format(time.RFC3339),
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 != "" { if post.Title != "" {
frontMatter["title"] = post.Title frontMatter["title"] = post.Title
} else if themeMeta.PreferTitle { } else if themeMeta.PreferTitle {
frontMatter["title"] = post.PostDate.Format(time.ANSIC) frontMatter["title"] = post.PublishDate.Format(time.ANSIC)
}
return s.writeMarkdownFile(postFilename, frontMatter, post.Body)
}
func (s *Service) writeMarkdownFile(outFile string, frontMatter map[string]any, body string) error {
if err := os.MkdirAll(filepath.Dir(outFile), 0755); err != nil {
return err
} }
fmBytes, err := yaml.Marshal(frontMatter) fmBytes, err := yaml.Marshal(frontMatter)
@ -124,7 +125,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
return err return err
} }
f, err := os.Create(postFilename) f, err := os.Create(outFile)
if err != nil { if err != nil {
return err return err
} }
@ -139,7 +140,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
if _, err := f.WriteString("---\n"); err != nil { if _, err := f.WriteString("---\n"); err != nil {
return err return err
} }
if _, err := f.WriteString(post.Body); err != nil { if _, err := f.WriteString(body); err != nil {
return err return err
} }
@ -147,5 +148,15 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
} }
func (s *Service) postFilename(site models.Site, themeMeta models.ThemeMeta, post models.Post) string { func (s *Service) postFilename(site models.Site, themeMeta models.ThemeMeta, post models.Post) string {
return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.PostDir, post.CreatedAt.Format("2006-01-02-150405.md")) return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), themeMeta.BlogPostBundle, post.CreatedAt.Format("2006-01-02-150405.md"))
}
func (s *Service) pageFilename(site models.Site, bundle models.Bundle, page models.Page) string {
bundleDir := ""
if bundle.Name != models.RootBundleName {
bundleDir = bundle.Name
}
pageName := page.Name + ".md"
return filepath.Join(s.hugo.SiteStagingDir(site, hugo.ContentSiteDir), bundleDir, pageName)
} }

View file

@ -79,6 +79,10 @@ func (s *Service) rebuildSite(ctx context.Context, oldSite, newSite models.Site)
return err return err
} }
if err := s.writeAllPages(ctx, newSite); err != nil {
return err
}
return s.publish(ctx, newSite) return s.publish(ctx, newSite)
} }
@ -148,3 +152,4 @@ func (s *Service) signalSiteBuildingStarted(ctx context.Context, site models.Sit
func (s *Service) signalSiteBuildingFinished(ctx context.Context, site models.Site) { func (s *Service) signalSiteBuildingFinished(ctx context.Context, site models.Site) {
s.bus.Fire(models.Event{Type: models.EventSiteBuildingDone, Data: site}) s.bus.Fire(models.Event{Type: models.EventSiteBuildingDone, Data: site})
} }

View file

@ -0,0 +1,32 @@
package sitebuilder
import (
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/providers/bus"
)
type SiteBuildingTracker struct {
bus *bus.Bus
isBuildingState map[int64]models.Site
}
func NewSiteBuildingTracker(bus *bus.Bus) *SiteBuildingTracker {
return &SiteBuildingTracker{
bus: bus,
isBuildingState: map[int64]models.Site{},
}
}
func (sbt *SiteBuildingTracker) Listen() {
sub := sbt.bus.Subscribe()
for e := range sub.C {
switch e.Type {
case models.EventSiteBuildingStart:
site := e.Data.(models.Site)
sbt.isBuildingState[site.ID] = site
case models.EventSiteBuildingDone:
delete(sbt.isBuildingState, e.Data.(models.Site).ID)
}
}
}

55
services/sites/create.go Normal file
View file

@ -0,0 +1,55 @@
package sites
import (
"context"
"errors"
"lmika.dev/lmika/hugo-cms/models"
"time"
)
func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) {
// Create a new site
newSite := models.Site{
Name: normaliseName(name),
OwnerUserID: user.ID,
Title: name,
Theme: "bear",
}
_, ok := s.themes.Lookup(newSite.Theme)
if !ok {
return models.Site{}, errors.New("theme not found")
}
if err := s.db.InsertSite(ctx, &newSite); err != nil {
return models.Site{}, err
}
// Add the default page bundle
rootBundle := models.Bundle{
SiteID: newSite.ID,
Name: models.RootBundleName,
CreatedAt: time.Now(),
}
if err := s.db.InsertBundle(ctx, &rootBundle); err != nil {
return models.Site{}, err
}
// TEMP: Add a home page
homePage := models.Page{
SiteID: newSite.ID,
BundleID: rootBundle.ID,
Name: "index",
Title: "Welcome to the home page",
Body: "This is the home page",
State: models.PostStatePublished,
PublishDate: time.Now(),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
if err := s.db.InsertPage(ctx, &homePage); err != nil {
return models.Site{}, err
}
return newSite, s.jobs.Queue(ctx, s.sb.RebuildSite(newSite, newSite))
}

View file

@ -46,27 +46,6 @@ func (s *Service) GetProdTargetOfSite(ctx context.Context, siteID int) (models.P
return s.db.GetPublishTargetBySiteRole(ctx, int64(siteID), models.TargetRoleProduction) return s.db.GetPublishTargetBySiteRole(ctx, int64(siteID), models.TargetRoleProduction)
} }
func (s *Service) CreateSite(ctx context.Context, user models.User, name string) (models.Site, error) {
newSite := models.Site{
Name: normaliseName(name),
OwnerUserID: user.ID,
Title: name,
Theme: "bear",
//Theme: "yingyang",
}
_, ok := s.themes.Lookup(newSite.Theme)
if !ok {
return models.Site{}, errors.New("theme not found")
}
if err := s.db.InsertSite(ctx, &newSite); err != nil {
return models.Site{}, err
}
return newSite, s.jobs.Queue(ctx, s.sb.CreateNewSite(newSite))
}
func (s *Service) SaveSettings(ctx context.Context, site models.Site, newSettings NewSettings) error { func (s *Service) SaveSettings(ctx context.Context, site models.Site, newSettings NewSettings) error {
_, ok := s.themes.Lookup(newSettings.SiteTheme) _, ok := s.themes.Lookup(newSettings.SiteTheme)
if !ok { if !ok {

13
sql/queries/bundles.sql Normal file
View file

@ -0,0 +1,13 @@
-- name: InsertBundle :one
INSERT INTO bundles (
site_id,
name,
created_at,
updated_at
) VALUES ($1, $2, $3, $3) RETURNING id;
-- name: ListBundles :many
SELECT * FROM bundles WHERE site_id = $1;
-- name: GetBundleWithID :one
SELECT * FROM bundles WHERE id = $1;

47
sql/queries/pages.sql Normal file
View file

@ -0,0 +1,47 @@
-- name: InsertPage :one
INSERT INTO pages (
site_id,
bundle_id,
name,
name_provenance,
title,
role,
body,
state,
props,
publish_date,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
RETURNING id;
-- name: UpdatePage :exec
UPDATE pages SET
site_id = $2,
bundle_id = $3,
name = $4,
name_provenance = $5,
title = $6,
role = $7,
body = $8,
state = $9,
props = $10,
publish_date = $11,
created_at = $12,
updated_at = $13
WHERE id = $1;
-- name: ListPublishablePages :many
SELECT *
FROM pages
WHERE id > $1 AND site_id = $2 AND state = 'published'
ORDER BY id LIMIT 100;
-- name: ListPages :many
SELECT * FROM pages WHERE site_id = $1 ORDER BY name ASC LIMIT 25;
-- name: GetPageWithID :one
SELECT * FROM pages WHERE id = $1;
-- name: DeletePageWithID :exec
DELETE FROM pages WHERE id = $1;

View file

@ -1,5 +1,5 @@
-- name: ListPosts :many -- name: ListPosts :many
SELECT * FROM posts WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; SELECT * FROM posts WHERE site_id = $1 ORDER BY publish_date DESC LIMIT 25;
-- name: GetPostWithID :one -- name: GetPostWithID :one
SELECT * FROM posts WHERE id = $1 LIMIT 1; SELECT * FROM posts WHERE id = $1 LIMIT 1;
@ -7,7 +7,7 @@ SELECT * FROM posts WHERE id = $1 LIMIT 1;
-- name: ListPublishablePosts :many -- name: ListPublishablePosts :many
SELECT * SELECT *
FROM posts FROM posts
WHERE id > $1 AND site_id = $2 AND state = 'published' AND post_date <= $3 WHERE id > $1 AND site_id = $2 AND state = 'published' AND publish_date <= $3
ORDER BY id LIMIT 100; ORDER BY id LIMIT 100;
-- name: InsertPost :one -- name: InsertPost :one
@ -17,9 +17,10 @@ INSERT INTO posts (
body, body,
state, state,
props, props,
post_date, publish_date,
created_at created_at,
) VALUES ($1, $2, $3, $4, $5, $6, $7) updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id; RETURNING id;
-- name: UpdatePost :exec -- name: UpdatePost :exec
@ -29,8 +30,8 @@ UPDATE posts SET
body = $4, body = $4,
state = $5, state = $5,
props = $6, props = $6,
post_date = $7 publish_date = $7,
-- updated_at = $7 updated_at = $8
WHERE id = $1; WHERE id = $1;
-- name: DeletePost :exec -- name: DeletePost :exec

View file

@ -11,6 +11,12 @@ CREATE TYPE target_type AS ENUM (
'netlify' 'netlify'
); );
CREATE TYPE page_name_provenance AS ENUM (
'user',
'title',
'date'
);
CREATE TABLE users ( CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE, email TEXT NOT NULL UNIQUE,
@ -28,19 +34,63 @@ CREATE TABLE sites (
FOREIGN KEY (owner_user_id) REFERENCES users (id) FOREIGN KEY (owner_user_id) REFERENCES users (id)
); );
-- Post role is used to describe a specific kind of post, such as a link.
-- When set, it specifies the layout to use for the page
CREATE TABLE post_roles (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
layout_name TEXT NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
);
CREATE TABLE posts ( CREATE TABLE posts (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL, site_id BIGINT NOT NULL,
title TEXT, title TEXT,
role BIGINT,
body TEXT NOT NULL, body TEXT NOT NULL,
state post_state NOT NULL, state post_state NOT NULL,
props JSON NOT NULL, props JSON NOT NULL,
post_date TIMESTAMP WITH TIME ZONE, publish_date TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP NOT NULL, created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE,
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
);
CREATE TABLE bundles (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE
); );
CREATE TABLE pages (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,
bundle_id BIGINT NOT NULL,
name TEXT NOT NULL,
name_provenance page_name_provenance NOT NULL,
title TEXT,
role BIGINT,
body TEXT NOT NULL,
state post_state NOT NULL,
props JSON NOT NULL,
publish_date TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (bundle_id, name),
FOREIGN KEY (site_id) REFERENCES sites (id) ON DELETE CASCADE,
FOREIGN KEY (role) REFERENCES post_roles (id) ON DELETE CASCADE,
FOREIGN KEY (bundle_id) REFERENCES sites (id) ON DELETE CASCADE
);
CREATE TABLE publish_targets ( CREATE TABLE publish_targets (
id BIGSERIAL NOT NULL PRIMARY KEY, id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL, site_id BIGINT NOT NULL,

View file

@ -6,5 +6,6 @@ import "embed"
//go:embed auth/*.html //go:embed auth/*.html
//go:embed layouts/*.html //go:embed layouts/*.html
//go:embed posts/*.html //go:embed posts/*.html
//go:embed pages/*.html
//go:embed sites/*.html //go:embed sites/*.html
var FS embed.FS var FS embed.FS

13
templates/pages/edit.html Normal file
View file

@ -0,0 +1,13 @@
{{- $postTarget := printf "/sites/%v/pages" .site.ID -}}
{{- if (ne .page.ID 0) -}}
{{- $postTarget = printf "/sites/%v/pages/%v" .site.ID .page.ID -}}
{{- end -}}
<form method="post" action="{{$postTarget}}" class="post-form">
<input name="title" placeholder="Title" value="{{.page.Title}}">
<textarea name="body">{{.page.Body}}</textarea>
<div class="bottom-bar">
<input type="submit" value="Post">
</div>
</form>

View file

@ -0,0 +1,20 @@
<div>
<a href="/sites/{{.site.ID}}/pages/new">New Page</a>
</div>
{{range .pages}}
<div class="post">
{{if .Title}}
<h3>{{.Title}}</h3>
{{end}}
{{.Body | markdown}}
<div>
<a href="/sites/{{$.site.ID}}/pages/{{.ID}}">Edit</a> |
<a hx-delete="/sites/{{$.site.ID}}/pages/{{.ID}}" hx-confirm="Delete page?" hx-target="closest .post" href="#">Delete</a>
</div>
</div>
{{else}}
<p>No pages yet</p>
{{end}}