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"
)
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
const (
@ -135,15 +178,47 @@ func (ns NullTargetType) Value() (driver.Value, error) {
return string(ns.TargetType), nil
}
type Post struct {
type Bundle struct {
ID int64
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
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 {
ID int64
SiteID int64
Title pgtype.Text
Role pgtype.Int8
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
type PostRole struct {
ID int64
SiteID int64
LayoutName string
}
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
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) {
@ -31,11 +31,13 @@ func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) {
&i.ID,
&i.SiteID,
&i.Title,
&i.Role,
&i.Body,
&i.State,
&i.Props,
&i.PostDate,
&i.PublishDate,
&i.CreatedAt,
&i.UpdatedAt,
)
return i, err
}
@ -47,20 +49,22 @@ INSERT INTO posts (
body,
state,
props,
post_date,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
publish_date,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`
type InsertPostParams struct {
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
CreatedAt pgtype.Timestamp
UpdatedAt pgtype.Timestamp
}
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.State,
arg.Props,
arg.PostDate,
arg.PublishDate,
arg.CreatedAt,
arg.UpdatedAt,
)
var id int64
err := row.Scan(&id)
@ -79,7 +84,7 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
}
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) {
@ -95,11 +100,13 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
&i.ID,
&i.SiteID,
&i.Title,
&i.Role,
&i.Body,
&i.State,
&i.Props,
&i.PostDate,
&i.PublishDate,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
@ -112,20 +119,20 @@ func (q *Queries) ListPosts(ctx context.Context, siteID int64) ([]Post, error) {
}
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
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
`
type ListPublishablePostsParams struct {
ID int64
SiteID int64
PostDate pgtype.Timestamptz
ID int64
SiteID int64
PublishDate pgtype.Timestamptz
}
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 {
return nil, err
}
@ -137,11 +144,13 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP
&i.ID,
&i.SiteID,
&i.Title,
&i.Role,
&i.Body,
&i.State,
&i.Props,
&i.PostDate,
&i.PublishDate,
&i.CreatedAt,
&i.UpdatedAt,
); err != nil {
return nil, err
}
@ -160,19 +169,20 @@ UPDATE posts SET
body = $4,
state = $5,
props = $6,
post_date = $7
-- updated_at = $7
publish_date = $7,
updated_at = $8
WHERE id = $1
`
type UpdatePostParams struct {
ID int64
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PostDate pgtype.Timestamptz
ID int64
SiteID int64
Title pgtype.Text
Body string
State PostState
Props []byte
PublishDate pgtype.Timestamptz
UpdatedAt pgtype.Timestamp
}
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.State,
arg.Props,
arg.PostDate,
arg.PublishDate,
arg.UpdatedAt,
)
return err
}

2
go.mod
View file

@ -48,5 +48,5 @@ require (
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-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-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-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/themes"
"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/sitebuilder"
"lmika.dev/lmika/hugo-cms/services/sites"
@ -85,10 +86,12 @@ func main() {
siteService := sites.NewService(cfg, dbp, themesProvider, siteBuilderService, jobService)
postService := posts.New(dbp, siteBuilderService, jobService)
pageService := pages.New(dbp, siteBuilderService, jobService)
indexHandlers := handlers.IndexHandler{}
siteHandlers := handlers.Site{Site: siteService, Bus: bus}
postHandlers := handlers.Post{Post: postService}
pageHandlers := handlers.Pages{Svc: pageService}
authHandlers := handlers.AuthHandler{UserService: userService}
tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html")
@ -137,6 +140,13 @@ func main() {
sr.Post("/posts/:postId", postHandlers.Update)
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.Post("/settings", siteHandlers.SaveSettings)
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

@ -10,13 +10,13 @@ const (
)
type Post struct {
ID int64
SiteID int64
OwnerID int64
Title string
Body string
State PostState
PostDate time.Time
CreatedAt time.Time
UpdatedAt time.Time
ID int64
SiteID int64
OwnerID int64
Title string
Body string
State PostState
PublishDate time.Time
CreatedAt time.Time
UpdatedAt time.Time
}

View file

@ -8,6 +8,6 @@ type ThemeMeta struct {
// Indicates that this theme prefers posts have titles.
PreferTitle bool
// Content directory for "blog" posts
PostDir string `json:"post_dir"`
// Page bundle for "blog" posts
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

@ -33,9 +33,9 @@ func (db *DB) DeletePost(ctx context.Context, postID int64) error {
func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, now time.Time) ([]models.Post, error) {
res, err := db.q.ListPublishablePosts(ctx, dbq.ListPublishablePostsParams{
ID: fromID,
SiteID: siteID,
PostDate: pgtype.Timestamptz{Time: now, Valid: true},
ID: fromID,
SiteID: siteID,
PublishDate: pgtype.Timestamptz{Time: now, Valid: true},
})
if err != nil {
return nil, err
@ -46,13 +46,14 @@ func (db *DB) ListPublishablePosts(ctx context.Context, fromID, siteID int64, no
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()},
SiteID: p.SiteID,
Title: pgtype.Text{String: p.Title, Valid: p.Title != ""},
Body: p.Body,
State: dbq.PostState(p.State),
Props: []byte(`{}`),
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()},
})
if err != nil {
return err
@ -64,25 +65,25 @@ func (db *DB) InsertPost(ctx context.Context, p *models.Post) error {
func (db *DB) UpdatePost(ctx context.Context, p *models.Post) error {
return db.q.UpdatePost(ctx, dbq.UpdatePostParams{
ID: p.ID,
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()},
ID: p.ID,
SiteID: p.SiteID,
Title: pgtype.Text{String: p.Title, Valid: p.Title != ""},
Body: p.Body,
State: dbq.PostState(p.State),
Props: []byte(`{}`),
PublishDate: pgtype.Timestamptz{Time: p.PublishDate, Valid: !p.PublishDate.IsZero()},
UpdatedAt: pgtype.Timestamp{Time: p.UpdatedAt, Valid: !p.UpdatedAt.IsZero()},
})
}
func dbPostToPost(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,
ID: p.ID,
SiteID: p.SiteID,
Title: p.Title.String,
Body: p.Body,
State: models.PostState(p.State),
PublishDate: p.PublishDate.Time,
CreatedAt: p.CreatedAt.Time,
}
}

View file

@ -4,24 +4,24 @@ import "lmika.dev/lmika/hugo-cms/models"
var themes = []models.ThemeMeta{
{
ID: "bear",
Name: "Bear",
URL: "https://github.com/janraasch/hugo-bearblog",
PreferTitle: true,
PostDir: "blog",
ID: "bear",
Name: "Bear",
URL: "https://github.com/janraasch/hugo-bearblog",
PreferTitle: true,
BlogPostBundle: "blog",
},
{
ID: "terminal",
Name: "Terminal",
URL: "https://github.com/panr/hugo-theme-terminal",
PreferTitle: true,
PostDir: "posts",
ID: "terminal",
Name: "Terminal",
URL: "https://github.com/panr/hugo-theme-terminal",
PreferTitle: true,
BlogPostBundle: "posts",
},
{
ID: "yingyang",
Name: "Yingyang",
URL: "https://github.com/joway/hugo-theme-yinyang",
PreferTitle: true,
PostDir: "posts",
ID: "yingyang",
Name: "Yingyang",
URL: "https://github.com/joway/hugo-theme-yinyang",
PreferTitle: true,
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

@ -55,11 +55,11 @@ func (s *Service) DeletePost(ctx context.Context, site models.Site, id int) erro
func (s *Service) Create(ctx context.Context, site models.Site, req NewPost) (models.Post, error) {
post := models.Post{
SiteID: site.ID,
Title: req.Title,
Body: req.Body,
State: models.PostStatePublished,
PostDate: time.Now(),
SiteID: site.ID,
Title: req.Title,
Body: req.Body,
State: models.PostStatePublished,
PublishDate: time.Now(),
}
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"
"lmika.dev/lmika/hugo-cms/models"
"lmika.dev/lmika/hugo-cms/providers/hugo"
"log"
"os"
"path/filepath"
"time"
@ -104,19 +103,21 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
postFilename := s.postFilename(site, themeMeta, post)
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),
frontMatter := map[string]any{
"date": post.PublishDate.Format(time.RFC3339),
}
if post.Title != "" {
frontMatter["title"] = post.Title
} 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)
@ -124,7 +125,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
return err
}
f, err := os.Create(postFilename)
f, err := os.Create(outFile)
if err != nil {
return err
}
@ -139,7 +140,7 @@ func (s *Service) writePost(site models.Site, post models.Post) error {
if _, err := f.WriteString("---\n"); err != nil {
return err
}
if _, err := f.WriteString(post.Body); err != nil {
if _, err := f.WriteString(body); err != nil {
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 {
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
}
if err := s.writeAllPages(ctx, newSite); err != nil {
return err
}
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) {
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)
}
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 {
_, ok := s.themes.Lookup(newSettings.SiteTheme)
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
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
SELECT * FROM posts WHERE id = $1 LIMIT 1;
@ -7,7 +7,7 @@ SELECT * FROM posts WHERE id = $1 LIMIT 1;
-- name: ListPublishablePosts :many
SELECT *
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;
-- name: InsertPost :one
@ -17,9 +17,10 @@ INSERT INTO posts (
body,
state,
props,
post_date,
created_at
) VALUES ($1, $2, $3, $4, $5, $6, $7)
publish_date,
created_at,
updated_at
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id;
-- name: UpdatePost :exec
@ -29,8 +30,8 @@ UPDATE posts SET
body = $4,
state = $5,
props = $6,
post_date = $7
-- updated_at = $7
publish_date = $7,
updated_at = $8
WHERE id = $1;
-- name: DeletePost :exec

View file

@ -11,6 +11,12 @@ CREATE TYPE target_type AS ENUM (
'netlify'
);
CREATE TYPE page_name_provenance AS ENUM (
'user',
'title',
'date'
);
CREATE TABLE users (
id BIGSERIAL NOT NULL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
@ -28,19 +34,63 @@ CREATE TABLE sites (
FOREIGN KEY (owner_user_id) REFERENCES users (id)
);
CREATE TABLE posts (
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,
-- 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 (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT 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,
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
);
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 (
id BIGSERIAL NOT NULL PRIMARY KEY,
site_id BIGINT NOT NULL,

View file

@ -6,5 +6,6 @@ import "embed"
//go:embed auth/*.html
//go:embed layouts/*.html
//go:embed posts/*.html
//go:embed pages/*.html
//go:embed sites/*.html
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}}