From bf5d6cbe52aa5288fa6ea94c971d15c64e1b59cf Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 27 Jan 2025 21:45:54 +1100 Subject: [PATCH] Started styling the app and have got editing posts working. --- .air.toml | 48 +++++++++++++++++++++ Makefile | 13 ++++++ assets/css/main.css | 53 +++++++++++++++++++++++ assets/css/reset.css | 76 +++++++++++++++++++++++++++++++++ assets/fs.go | 6 +++ gen/sqlc/dbq/posts.sql.go | 55 ++++++++++++++++++++++++ go.mod | 1 + go.sum | 2 + handlers/post.go | 84 +++++++++++++++++++++++++++++++++++-- main.go | 31 +++++++++++--- models/posts.go | 1 + providers/db/posts.go | 22 ++++++++++ services/posts/services.go | 54 +++++++++++++++++++----- sql/queries/posts.sql | 16 ++++++- templates/fs.go | 2 +- templates/layouts/main.html | 6 ++- templates/layouts/site.html | 30 +++++++++++++ templates/posts/index.html | 20 +++++++++ templates/posts/new.html | 13 ++++++ templates/sites/posts.html | 19 --------- 20 files changed, 511 insertions(+), 41 deletions(-) create mode 100644 .air.toml create mode 100644 Makefile create mode 100644 assets/css/main.css create mode 100644 assets/css/reset.css create mode 100644 assets/fs.go create mode 100644 templates/layouts/site.html create mode 100644 templates/posts/index.html create mode 100644 templates/posts/new.html delete mode 100644 templates/sites/posts.html diff --git a/.air.toml b/.air.toml new file mode 100644 index 0000000..b59a2f0 --- /dev/null +++ b/.air.toml @@ -0,0 +1,48 @@ +root = "." +testdata_dir = "testdata" +tmp_dir = "tmp" + +[build] +args_bin = [ + "-no-auth" +] +bin = "./build/hugo-crm" +cmd = "make compile" +delay = 1000 +exclude_dir = ["build"] +exclude_file = [] +exclude_regex = ["_test.go", "build/.*"] +exclude_unchanged = false +follow_symlink = false +full_bin = "export $(cat .env | xargs) ; cd build ; ./hugo-crm" +include_dir = [] +include_ext = ["go", "tpl", "tmpl", "html", "gohtml", "css", "js"] +include_file = [] +kill_delay = "0s" +log = "build-errors.log" +poll = false +poll_interval = 0 +post_cmd = [] +pre_cmd = [] +rerun = false +rerun_delay = 500 +send_interrupt = false +stop_on_error = false + +[color] +app = "" +build = "yellow" +main = "magenta" +runner = "green" +watcher = "cyan" + +[log] +main_only = false +time = false + +[misc] +clean_on_exit = true + +[screen] +clear_on_rebuild = false +keep_scroll = true \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ebddec1 --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.Phony: clean +clean: + -docker-compose down -v + -rm -r build + +.Phony: prep +prep: + -docker-compose up -d + mkdir -p build + +.Phony: compile +compile: prep + go build -o ./build/hugo-crm \ No newline at end of file diff --git a/assets/css/main.css b/assets/css/main.css new file mode 100644 index 0000000..143bbbe --- /dev/null +++ b/assets/css/main.css @@ -0,0 +1,53 @@ +html { + font-family: -apple-system, BlinkMacSystemFont, "Avenir Next", Avenir, + "Nimbus Sans L", Roboto, "Noto Sans", "Segoe UI", Arial, Helvetica, + "Helvetica Neue", sans-serif; +} + +body.role-site { + display: flex; + flex-direction: column; +} + +body.role-site header { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + padding-block: 1rem; +} + +body.role-site header h1 { + margin-block: 0; +} + +body.role-site .client-area { + flex-grow: 1; + flex-shrink: 1; + + display: flex; + flex-direction: row; +} + +body.role-site nav.vert { + width: 150px; +} + +body.role-site main { + flex-grow: 1; + flex-shrink: 1; +} + + + +form.post-form { + display: flex; + flex-direction: column; + height: 100%; +} + +form.post-form textarea { + flex-grow: 1; + flex-shrink: 1; +} \ No newline at end of file diff --git a/assets/css/reset.css b/assets/css/reset.css new file mode 100644 index 0000000..1c5345d --- /dev/null +++ b/assets/css/reset.css @@ -0,0 +1,76 @@ + +/* Box sizing rules */ +*, +*::before, +*::after { + box-sizing: border-box; +} + +/* Prevent font size inflation */ +html { + -moz-text-size-adjust: none; + -webkit-text-size-adjust: none; + text-size-adjust: none; +} + +/* Remove default margin in favour of better control in authored CSS */ +body, h1, h2, h3, h4, p, +figure, blockquote, dl, dd { + margin-block-end: 0; +} + +/* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ +ul[role='list'], +ol[role='list'] { + list-style: none; +} + +/* Set core body defaults */ +body { + min-height: 100vh; + margin-block: 0; + line-height: 1.5; +} + +/* Set shorter line heights on headings and interactive elements */ +h1, h2, h3, h4, +button, input, label { + line-height: 1.1; +} + +/* Balance text wrapping on headings */ +h1, h2, +h3, h4 { + text-wrap: balance; +} + +/* A elements that don't have a class get default styles */ +a:not([class]) { + text-decoration-skip-ink: auto; + color: currentColor; +} + +/* Make images easier to work with */ +img, +picture { + max-width: 100%; + display: block; +} + +/* Inherit fonts for inputs and buttons */ +input, button, +textarea, select { + font-family: inherit; + font-size: inherit; +} + +/* Make sure textareas without a rows attribute are not tiny */ +textarea:not([rows]) { + min-height: 10em; +} + +/* Anything that has been anchored to should have extra scroll margin */ +:target { + scroll-margin-block: 5ex; +} + diff --git a/assets/fs.go b/assets/fs.go new file mode 100644 index 0000000..c18514b --- /dev/null +++ b/assets/fs.go @@ -0,0 +1,6 @@ +package assets + +import "embed" + +//go:embed css/* +var FS embed.FS diff --git a/gen/sqlc/dbq/posts.sql.go b/gen/sqlc/dbq/posts.sql.go index 00bc58e..de5a4b3 100644 --- a/gen/sqlc/dbq/posts.sql.go +++ b/gen/sqlc/dbq/posts.sql.go @@ -11,6 +11,26 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const getPostWithID = `-- name: GetPostWithID :one +SELECT id, site_id, title, body, state, props, post_date, created_at FROM post WHERE id = $1 LIMIT 1 +` + +func (q *Queries) GetPostWithID(ctx context.Context, id int64) (Post, error) { + row := q.db.QueryRow(ctx, getPostWithID, id) + var i Post + err := row.Scan( + &i.ID, + &i.SiteID, + &i.Title, + &i.Body, + &i.State, + &i.Props, + &i.PostDate, + &i.CreatedAt, + ) + return i, err +} + const insertPost = `-- name: InsertPost :one INSERT INTO post ( site_id, @@ -123,3 +143,38 @@ func (q *Queries) ListPublishablePosts(ctx context.Context, arg ListPublishableP } return items, nil } + +const updatePost = `-- name: UpdatePost :exec +UPDATE post SET + site_id = $2, + title = $3, + body = $4, + state = $5, + props = $6, + post_date = $7 + -- updated_at = $7 +WHERE id = $1 +` + +type UpdatePostParams struct { + ID int64 + SiteID int64 + Title pgtype.Text + Body string + State PostState + Props []byte + PostDate pgtype.Timestamptz +} + +func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { + _, err := q.db.Exec(ctx, updatePost, + arg.ID, + arg.SiteID, + arg.Title, + arg.Body, + arg.State, + arg.Props, + arg.PostDate, + ) + return err +} diff --git a/go.mod b/go.mod index fe52690..6e7780d 100644 --- a/go.mod +++ b/go.mod @@ -36,6 +36,7 @@ require ( github.com/valyala/fasthttp v1.58.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/yuin/goldmark v1.7.8 // indirect go.uber.org/atomic v1.7.0 // indirect golang.org/x/crypto v0.32.0 // indirect golang.org/x/net v0.34.0 // indirect diff --git a/go.sum b/go.sum index 51217bd..b5d4c9b 100644 --- a/go.sum +++ b/go.sum @@ -66,6 +66,8 @@ github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7Fw github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= +github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= +github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= diff --git a/handlers/post.go b/handlers/post.go index b63852e..57727e0 100644 --- a/handlers/post.go +++ b/handlers/post.go @@ -3,6 +3,7 @@ package handlers import ( "fmt" "github.com/gofiber/fiber/v2" + "lmika.dev/lmika/hugo-crm/models" "lmika.dev/lmika/hugo-crm/services/posts" ) @@ -19,16 +20,28 @@ func (h *Post) Posts() fiber.Handler { return err } - return c.Render("sites/posts", fiber.Map{ + return c.Render("posts/index", fiber.Map{ "site": site, "posts": posts, - }, "layouts/main") + }, "layouts/site") + } +} + +func (h *Post) New() fiber.Handler { + return func(c *fiber.Ctx) error { + site := GetSite(c) + + return c.Render("posts/new", fiber.Map{ + "site": site, + "post": models.Post{}, + }, "layouts/site") } } func (h *Post) Create() fiber.Handler { type Req struct { - Body string `json:"body" form:"body"` + Title string `json:"title" form:"title"` + Body string `json:"body" form:"body"` } return func(c *fiber.Ctx) error { @@ -39,7 +52,10 @@ func (h *Post) Create() fiber.Handler { return err } - _, err := h.Post.Create(c.UserContext(), site, req.Body) + _, err := h.Post.Create(c.UserContext(), site, posts.NewPost{ + Title: req.Title, + Body: req.Body, + }) if err != nil { return err } @@ -47,3 +63,63 @@ func (h *Post) Create() fiber.Handler { return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID)) } } + +func (h *Post) Edit() fiber.Handler { + return func(c *fiber.Ctx) error { + site := GetSite(c) + + postID, err := c.ParamsInt("postId") + if err != nil { + return err + } + + post, err := h.Post.GetPost(c.UserContext(), postID) + if err != nil { + return err + } else if post.SiteID != site.ID { + return fmt.Errorf("post id %v not equal to site id %v", postID, site.ID) + } + + return c.Render("posts/new", fiber.Map{ + "site": site, + "post": post, + }, "layouts/site") + } +} + +func (h *Post) Update() fiber.Handler { + type Req struct { + Title string `json:"title" form:"title"` + Body string `json:"body" form:"body"` + } + + return func(c *fiber.Ctx) error { + site := GetSite(c) + + postID, err := c.ParamsInt("postId") + if err != nil { + return err + } + + var req Req + if err := c.BodyParser(&req); err != nil { + return err + } + + post, err := h.Post.GetPost(c.UserContext(), postID) + if err != nil { + return err + } else if post.SiteID != site.ID { + return fmt.Errorf("post id %v not equal to site id %v", postID, site.ID) + } + + post.Title = req.Title + post.Body = req.Body + + if err := h.Post.Save(c.UserContext(), site, &post); err != nil { + return err + } + + return c.Redirect(fmt.Sprintf("/sites/%v/posts", site.ID)) + } +} diff --git a/main.go b/main.go index 96ba629..7785ee2 100644 --- a/main.go +++ b/main.go @@ -1,9 +1,14 @@ package main import ( + "bytes" "context" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/filesystem" "github.com/gofiber/template/html/v2" + "github.com/yuin/goldmark" + "html/template" + "lmika.dev/lmika/hugo-crm/assets" "lmika.dev/lmika/hugo-crm/config" "lmika.dev/lmika/hugo-crm/handlers" "lmika.dev/lmika/hugo-crm/providers/db" @@ -57,6 +62,14 @@ func main() { log.Println("Database migrated") tmplEngine := html.NewFileSystem(http.FS(templates.FS), ".html") + tmplEngine.Funcmap["markdown"] = func(s string) (template.HTML, error) { + var buf bytes.Buffer + if err := goldmark.Convert([]byte(s), &buf); err != nil { + return "", err + } + return template.HTML(buf.String()), nil + } + app := fiber.New(fiber.Config{ Views: tmplEngine, }) @@ -68,13 +81,21 @@ func main() { app.Post("/sites", siteHandlers.Create()) app.Get("/sites/:siteId", siteHandlers.Show()) - siteGroup := app.Group("/sites/:siteId") - siteGroup.Use(siteHandlers.WithSite()) + app.Route("/sites/:siteId", func(r fiber.Router) { + r.Use(siteHandlers.WithSite()) + r.Post("/rebuild", siteHandlers.Rebuild()) - siteGroup.Post("/rebuild", siteHandlers.Rebuild()) + r.Get("/posts", postHandlers.Posts()) + r.Get("/posts/:postId", postHandlers.Edit()) + r.Get("/posts/new", postHandlers.New()) + r.Post("/posts", postHandlers.Create()) + r.Post("/posts/:postId", postHandlers.Update()) + }) - siteGroup.Get("/posts", postHandlers.Posts()) - siteGroup.Post("/posts", postHandlers.Create()) + app.Use("/assets", filesystem.New(filesystem.Config{ + Root: http.FS(assets.FS), + })) + app.Static("/assets", "./assets") jobService.Start() defer jobService.Stop() diff --git a/models/posts.go b/models/posts.go index 20d8c9a..104625f 100644 --- a/models/posts.go +++ b/models/posts.go @@ -17,4 +17,5 @@ type Post struct { State PostState PostDate time.Time CreatedAt time.Time + UpdatedAt time.Time } diff --git a/providers/db/posts.go b/providers/db/posts.go index cff8bb7..552b711 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -18,6 +18,15 @@ func (db *DB) ListPostsOfSite(ctx context.Context, siteID int64) ([]models.Post, return moslice.Map(res, dbPostToPost), nil } +func (db *DB) GetPost(ctx context.Context, postID int64) (models.Post, error) { + res, err := db.q.GetPostWithID(ctx, postID) + if err != nil { + return models.Post{}, err + } + + return dbPostToPost(res), nil +} + 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, @@ -49,6 +58,19 @@ func (db *DB) InsertPost(ctx context.Context, p *models.Post) error { return nil } +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()}, + }) +} + func dbPostToPost(p dbq.Post) models.Post { return models.Post{ ID: p.ID, diff --git a/services/posts/services.go b/services/posts/services.go index 78d0bd3..72f653f 100644 --- a/services/posts/services.go +++ b/services/posts/services.go @@ -31,17 +31,51 @@ func (s *Service) ListPostOfSite(ctx context.Context, site models.Site) ([]model 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 { +func (s *Service) GetPost(ctx context.Context, id int) (models.Post, error) { + post, err := s.db.GetPost(ctx, int64(id)) + if err != nil { return models.Post{}, err } - return post, s.jobs.Queue(ctx, s.sb.WritePost(site, post)) + return post, nil +} + +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(), + } + + if err := s.Save(ctx, site, &post); err != nil { + return models.Post{}, err + } + + return post, nil +} + +func (s *Service) Save(ctx context.Context, site models.Site, post *models.Post) error { + post.SiteID = site.ID + + if post.ID == 0 { + post.CreatedAt = time.Now() + post.UpdatedAt = time.Now() + if err := s.db.InsertPost(ctx, post); err != nil { + return err + } + } else { + post.UpdatedAt = time.Now() + if err := s.db.UpdatePost(ctx, post); err != nil { + return err + } + } + + return s.jobs.Queue(ctx, s.sb.WritePost(site, *post)) +} + +type NewPost struct { + Title string + Body string } diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 28cc808..34130ad 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -1,6 +1,9 @@ -- name: ListPosts :many SELECT * FROM post WHERE site_id = $1 ORDER BY post_date DESC LIMIT 25; +-- name: GetPostWithID :one +SELECT * FROM post WHERE id = $1 LIMIT 1; + -- name: ListPublishablePosts :many SELECT * FROM post @@ -17,4 +20,15 @@ INSERT INTO post ( post_date, created_at ) VALUES ($1, $2, $3, $4, $5, $6, $7) -RETURNING id; \ No newline at end of file +RETURNING id; + +-- name: UpdatePost :exec +UPDATE post SET + site_id = $2, + title = $3, + body = $4, + state = $5, + props = $6, + post_date = $7 + -- updated_at = $7 +WHERE id = $1; \ No newline at end of file diff --git a/templates/fs.go b/templates/fs.go index e2e8c6a..64b74d4 100644 --- a/templates/fs.go +++ b/templates/fs.go @@ -4,5 +4,5 @@ import "embed" //go:embed *.html //go:embed layouts/*.html -//go:embed sites/*.html +//go:embed posts/*.html var FS embed.FS diff --git a/templates/layouts/main.html b/templates/layouts/main.html index d58d5d8..822b957 100644 --- a/templates/layouts/main.html +++ b/templates/layouts/main.html @@ -1,7 +1,11 @@ - TEMP + + + + + Hugo CRM {{embed}} diff --git a/templates/layouts/site.html b/templates/layouts/site.html new file mode 100644 index 0000000..56dfe83 --- /dev/null +++ b/templates/layouts/site.html @@ -0,0 +1,30 @@ + + + + + + + + Hugo CRM + + +
+

Hugo CRM

+ +
+
+ +
{{embed}}
+
+ + \ No newline at end of file diff --git a/templates/posts/index.html b/templates/posts/index.html new file mode 100644 index 0000000..c297bba --- /dev/null +++ b/templates/posts/index.html @@ -0,0 +1,20 @@ +
+ New Post +
+ +{{range .posts}} + {{if .Title}} +

{{.Title}}

+ {{end}} + +
+ {{.Body | markdown}} + +
+ Edit +
+
+
+{{else}} +

No posts yet

+{{end}} \ No newline at end of file diff --git a/templates/posts/new.html b/templates/posts/new.html new file mode 100644 index 0000000..72769fa --- /dev/null +++ b/templates/posts/new.html @@ -0,0 +1,13 @@ +{{- $postTarget := printf "/sites/%v/posts" .site.ID -}} +{{- if .post.ID -}} + {{- $postTarget = printf "/sites/%v/posts/%v" .site.ID .post.ID -}} +{{- end -}} +
+ + + + +
+ +
+
diff --git a/templates/sites/posts.html b/templates/sites/posts.html deleted file mode 100644 index b847fd8..0000000 --- a/templates/sites/posts.html +++ /dev/null @@ -1,19 +0,0 @@ -

Site {{.site.Title}}

- -
- - -
- -
- -
- -{{range .posts}} -
- {{.Body}} -
-
-{{else}} -

No posts yet

-{{end}} \ No newline at end of file