Styled the post list and added updating of posts

This commit is contained in:
Leon Mika 2026-02-22 10:09:34 +11:00
parent e77cac2fd5
commit aef3bb6a1e
31 changed files with 1230 additions and 118 deletions

View file

@ -1,6 +1,6 @@
root = "." root = "."
testdata_dir = "testdata" testdata_dir = "testdata"
tmp_dir = "tmp" tmp_dir = "build/tmp"
[build] [build]
args_bin = [] args_bin = []
@ -14,7 +14,7 @@ tmp_dir = "tmp"
follow_symlink = false follow_symlink = false
full_bin = "" full_bin = ""
include_dir = [] include_dir = []
include_ext = ["go", "tpl", "tmpl", "html", "css", "js"] include_ext = ["go", "tpl", "tmpl", "html", "css", "scss", "js"]
include_file = [] include_file = []
kill_delay = "0s" kill_delay = "0s"
log = "build-errors.log" log = "build-errors.log"

View file

@ -13,7 +13,7 @@ clean:
.Phony: frontend .Phony: frontend
frontend: frontend:
npm install npm install
npx esbuild --bundle ./assets/css/main.css --outfile=./static/assets/main.css node esbuild.mjs
.Phony: gen .Phony: gen
gen: gen:

View file

@ -4,7 +4,6 @@ title: First Post
date: 2026-02-18T11:17:00Z date: 2026-02-18T11:17:00Z
tags: [] tags: []
slug: /2026/02/18/first-post slug: /2026/02/18/first-post
--- ---
Hello World! Hello World!

View file

@ -4,7 +4,6 @@ title: About a DB
date: 2026-02-19T11:17:00Z date: 2026-02-19T11:17:00Z
tags: [] tags: []
slug: /2026/02/19/about-a-db slug: /2026/02/19/about-a-db
--- ---
Hello again. Hello again.

View file

@ -4,7 +4,6 @@ title: Direct Publish To Netlify
date: 2026-02-20T06:36:00Z date: 2026-02-20T06:36:00Z
tags: [] tags: []
slug: /2026/02/20/netlify slug: /2026/02/20/netlify
--- ---
Just a quick one right now. Integrated the Netlify client allowing direct publish to Netlify. Just a quick one right now. Integrated the Netlify client allowing direct publish to Netlify.
Previous attempts were using the Netlify CLI, but after learning that Go has a Netlify client, Previous attempts were using the Netlify CLI, but after learning that Go has a Netlify client,

View file

@ -4,7 +4,6 @@ title: Success!
date: 2026-02-20T22:59:18Z date: 2026-02-20T22:59:18Z
tags: [] tags: []
slug: /2026/02/21/success slug: /2026/02/21/success
--- ---
Okay, publishing from the frontend works. Okay, publishing from the frontend works.

View file

@ -0,0 +1,10 @@
---
id: oV-tykrLgCoo
title: ""
date: 2026-02-21T23:08:52Z
tags: []
slug: /2026/02/22/have-got-the
---
Have got the post list looking decent. Although the edit links don't go anywhere.
But maybe if I try to update this post everything will work out okay.

View file

@ -0,0 +1,8 @@
---
id: OlI4xZu7SSS2
title: ""
date: 2026-02-21T22:11:28Z
tags: []
slug: /2026/02/22/with-a-bit
---
With a bit of luck, this should only be published locally, and not to Netlify.

View file

@ -1,4 +1,16 @@
@import "bootstrap/dist/css/bootstrap.css"; // Bootstrap customizations
$container-max-widths: (
sm: 540px,
md: 720px,
lg: 960px,
xl: 960px,
xxl: 960px
);
@import "bootstrap/scss/bootstrap.scss";
// Local classes
.post-form { .post-form {
display: grid; display: grid;

9
esbuild.mjs Normal file
View file

@ -0,0 +1,9 @@
import * as esbuild from 'esbuild'
import {sassPlugin} from 'esbuild-sass-plugin'
await esbuild.build({
entryPoints: ['./assets/css/main.scss'],
bundle: true,
plugins: [sassPlugin()],
outfile: './static/assets/main.css',
});

View file

@ -2,6 +2,7 @@ package handlers
import ( import (
"fmt" "fmt"
"strconv"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
@ -13,12 +14,43 @@ type PostsHandler struct {
} }
func (ph PostsHandler) Index(c fiber.Ctx) error { func (ph PostsHandler) Index(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{}) posts, err := ph.PostService.ListPosts(c.Context())
if err != nil {
return err
}
return c.Render("posts/index", fiber.Map{
"posts": posts,
})
} }
func (ph PostsHandler) New(c fiber.Ctx) error { func (ph PostsHandler) New(c fiber.Ctx) error {
return c.Render("posts/new", fiber.Map{ p := models.Post{
"guid": models.NewNanoID(), GUID: models.NewNanoID(),
}
return c.Render("posts/edit", fiber.Map{
"post": p,
})
}
func (ph PostsHandler) Edit(c fiber.Ctx) error {
postIDStr := c.Params("postID")
if postIDStr == "" {
return fiber.ErrBadRequest
}
postID, err := strconv.ParseInt(postIDStr, 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
post, err := ph.PostService.GetPost(c.Context(), postID)
if err != nil {
return err
}
return c.Render("posts/edit", fiber.Map{
"post": post,
}) })
} }

24
main.go
View file

@ -1,11 +1,16 @@
package main package main
import ( import (
"html"
"html/template"
"log" "log"
"strings"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/static" "github.com/gofiber/fiber/v3/middleware/static"
"github.com/gofiber/template/html/v3" fiber_html "github.com/gofiber/template/html/v3"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension"
"lmika.dev/lmika/weiro/handlers" "lmika.dev/lmika/weiro/handlers"
"lmika.dev/lmika/weiro/handlers/middleware" "lmika.dev/lmika/weiro/handlers/middleware"
"lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/db"
@ -36,8 +41,22 @@ func main() {
// } // }
//} //}
fiberTemplate := fiber_html.New("./views", ".html")
fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML {
mdParser := goldmark.New(
goldmark.WithExtensions(extension.GFM),
)
return func(s string) template.HTML {
var sb strings.Builder
if err := mdParser.Convert([]byte(s), &sb); err != nil {
return template.HTML("Markdown error: " + html.EscapeString(err.Error()))
}
return template.HTML(sb.String())
}
}()
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
Views: html.New("./views", ".html"), Views: fiberTemplate,
ViewsLayout: "layouts/main", ViewsLayout: "layouts/main",
PassLocalsToViews: true, PassLocalsToViews: true,
}) })
@ -48,6 +67,7 @@ func main() {
siteGroup.Get("/posts", ph.Index) siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New) siteGroup.Get("/posts/new", ph.New)
siteGroup.Get("/posts/:postID/edit", ph.Edit)
siteGroup.Post("/posts", ph.Update) siteGroup.Post("/posts", ph.Update)
app.Get("/", func(c fiber.Ctx) error { app.Get("/", func(c fiber.Ctx) error {

View file

@ -20,6 +20,7 @@ type Site struct {
type SitePublishTarget struct { type SitePublishTarget struct {
ID int64 ID int64
SiteID int64 SiteID int64
Enabled bool
BaseURL string BaseURL string
TargetType string TargetType string

965
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -3,6 +3,7 @@
"esbuild": "0.27.3" "esbuild": "0.27.3"
}, },
"dependencies": { "dependencies": {
"bootstrap": "^5.3.8" "bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
} }
} }

View file

@ -19,6 +19,7 @@ type PublishTarget struct {
ID int64 ID int64
SiteID int64 SiteID int64
TargetType string TargetType string
Enabled int64
BaseUrl string BaseUrl string
TargetRef string TargetRef string
TargetKey string TargetKey string

View file

@ -47,6 +47,26 @@ func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64,
return id, err return id, err
} }
const selectPost = `-- name: SelectPost :one
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE id = ? LIMIT 1
`
func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
row := q.db.QueryRowContext(ctx, selectPost, id)
var i Post
err := row.Scan(
&i.ID,
&i.SiteID,
&i.Guid,
&i.Title,
&i.Body,
&i.Slug,
&i.CreatedAt,
&i.PublishedAt,
)
return i, err
}
const selectPostByGUID = `-- name: SelectPostByGUID :one const selectPostByGUID = `-- name: SelectPostByGUID :one
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1 SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE guid = ? LIMIT 1
` `

View file

@ -13,16 +13,18 @@ const insertPublishTarget = `-- name: InsertPublishTarget :one
INSERT INTO publish_targets ( INSERT INTO publish_targets (
site_id, site_id,
target_type, target_type,
enabled,
base_url, base_url,
target_ref, target_ref,
target_key target_key
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id RETURNING id
` `
type InsertPublishTargetParams struct { type InsertPublishTargetParams struct {
SiteID int64 SiteID int64
TargetType string TargetType string
Enabled int64
BaseUrl string BaseUrl string
TargetRef string TargetRef string
TargetKey string TargetKey string
@ -32,6 +34,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
row := q.db.QueryRowContext(ctx, insertPublishTarget, row := q.db.QueryRowContext(ctx, insertPublishTarget,
arg.SiteID, arg.SiteID,
arg.TargetType, arg.TargetType,
arg.Enabled,
arg.BaseUrl, arg.BaseUrl,
arg.TargetRef, arg.TargetRef,
arg.TargetKey, arg.TargetKey,
@ -42,7 +45,7 @@ func (q *Queries) InsertPublishTarget(ctx context.Context, arg InsertPublishTarg
} }
const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many const selectPublishTargetsOfSite = `-- name: SelectPublishTargetsOfSite :many
SELECT id, site_id, target_type, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ? SELECT id, site_id, target_type, enabled, base_url, target_ref, target_key FROM publish_targets WHERE site_id = ?
` `
func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) { func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64) ([]PublishTarget, error) {
@ -58,6 +61,7 @@ func (q *Queries) SelectPublishTargetsOfSite(ctx context.Context, siteID int64)
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.TargetType, &i.TargetType,
&i.Enabled,
&i.BaseUrl, &i.BaseUrl,
&i.TargetRef, &i.TargetRef,
&i.TargetKey, &i.TargetKey,

View file

@ -21,6 +21,15 @@ func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*mod
return posts, nil return posts, nil
} }
func (db *Provider) SelectPost(ctx context.Context, postID int64) (*models.Post, error) {
row, err := db.queries.SelectPost(ctx, postID)
if err != nil {
return nil, err
}
return dbPostToPost(row), nil
}
func (db *Provider) SelectPostByGUID(ctx context.Context, guid string) (*models.Post, error) { func (db *Provider) SelectPostByGUID(ctx context.Context, guid string) (*models.Post, error) {
row, err := db.queries.SelectPostByGUID(ctx, guid) row, err := db.queries.SelectPostByGUID(ctx, guid)
if err != nil { if err != nil {

View file

@ -18,6 +18,7 @@ func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64
targets[i] = models.SitePublishTarget{ targets[i] = models.SitePublishTarget{
ID: row.ID, ID: row.ID,
SiteID: row.SiteID, SiteID: row.SiteID,
Enabled: row.Enabled != 0,
TargetType: row.TargetType, TargetType: row.TargetType,
BaseURL: row.BaseUrl, BaseURL: row.BaseUrl,
TargetRef: row.TargetRef, TargetRef: row.TargetRef,
@ -28,10 +29,16 @@ func (db *Provider) SelectPublishTargetsOfSite(ctx context.Context, siteID int64
} }
func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePublishTarget) error { func (db *Provider) SavePublishTarget(ctx context.Context, target *models.SitePublishTarget) error {
var enabled int64
if target.Enabled {
enabled = 1
}
if target.ID == 0 { if target.ID == 0 {
newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{ newID, err := db.queries.InsertPublishTarget(ctx, sqlgen.InsertPublishTargetParams{
SiteID: target.SiteID, SiteID: target.SiteID,
TargetType: target.TargetType, TargetType: target.TargetType,
Enabled: enabled,
BaseUrl: target.BaseURL, BaseUrl: target.BaseURL,
TargetRef: target.TargetRef, TargetRef: target.TargetRef,
TargetKey: target.TargetKey, TargetKey: target.TargetKey,

65
services/posts/create.go Normal file
View file

@ -0,0 +1,65 @@
package posts
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
)
type CreatePostParams struct {
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
}
func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
post, err := s.fetchOrCreatePost(ctx, site, params)
if err != nil {
return nil, err
}
post.Title = params.Title
post.Body = params.Body
post.PublishedAt = time.Now()
post.Slug = post.BestSlug()
if err := s.db.SavePost(ctx, post); err != nil {
return nil, err
}
// TODO: do on separate thread
if err := s.publisher.Publish(ctx, site); err != nil {
return nil, err
}
return post, nil
}
func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, params CreatePostParams) (*models.Post, error) {
post, err := s.db.SelectPostByGUID(ctx, params.GUID)
if err == nil {
if post.SiteID != site.ID {
return nil, models.NotFoundError
}
return post, nil
} else if !db.ErrorIsNoRows(err) {
return nil, err
}
post = &models.Post{
SiteID: site.ID,
GUID: params.GUID,
Title: params.Title,
Body: params.Body,
CreatedAt: time.Now(),
}
return post, nil
}

37
services/posts/list.go Normal file
View file

@ -0,0 +1,37 @@
package posts
import (
"context"
"lmika.dev/lmika/weiro/models"
)
func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
posts, err := s.db.SelectPostsOfSite(ctx, site.ID)
if err != nil {
return nil, err
}
return posts, nil
}
func (s *Service) GetPost(ctx context.Context, pid int64) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
post, err := s.db.SelectPost(ctx, pid)
if err != nil {
return nil, err
} else if post.SiteID != site.ID {
return nil, models.NotFoundError
}
return post, nil
}

View file

@ -1,10 +1,6 @@
package posts package posts
import ( import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db" "lmika.dev/lmika/weiro/providers/db"
"lmika.dev/lmika/weiro/services/publisher" "lmika.dev/lmika/weiro/services/publisher"
) )
@ -20,59 +16,3 @@ func New(db *db.Provider, publisher *publisher.Publisher) *Service {
publisher: publisher, publisher: publisher,
} }
} }
type CreatePostParams struct {
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
}
func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
}
post, err := s.fetchOrCreatePost(ctx, site, params)
if err != nil {
return nil, err
}
post.Title = params.Title
post.Body = params.Body
post.PublishedAt = time.Now()
post.Slug = post.BestSlug()
if err := s.db.SavePost(ctx, post); err != nil {
return nil, err
}
// TODO: do on separate thread
if err := s.publisher.Publish(ctx, site); err != nil {
return nil, err
}
return post, nil
}
func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, params CreatePostParams) (*models.Post, error) {
post, err := s.db.SelectPostByGUID(ctx, params.GUID)
if err == nil {
if post.SiteID != site.ID {
return nil, models.NotFoundError
}
return post, nil
} else if !db.ErrorIsNoRows(err) {
return nil, err
}
post = &models.Post{
SiteID: site.ID,
GUID: params.GUID,
Title: params.Title,
Body: params.Body,
CreatedAt: time.Now(),
}
return post, nil
}

View file

@ -41,6 +41,10 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
} }
for _, target := range targets { for _, target := range targets {
if !target.Enabled {
continue
}
pubSite := pubmodel.Site{ pubSite := pubmodel.Site{
Site: site, Site: site,
Posts: posts, Posts: posts,

View file

@ -1,6 +1,9 @@
-- name: SelectPostsOfSite :many -- name: SelectPostsOfSite :many
SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10; SELECT * FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10;
-- name: SelectPost :one
SELECT * FROM posts WHERE id = ? LIMIT 1;
-- name: SelectPostByGUID :one -- name: SelectPostByGUID :one
SELECT * FROM posts WHERE guid = ? LIMIT 1; SELECT * FROM posts WHERE guid = ? LIMIT 1;

View file

@ -5,8 +5,9 @@ SELECT * FROM publish_targets WHERE site_id = ?;
INSERT INTO publish_targets ( INSERT INTO publish_targets (
site_id, site_id,
target_type, target_type,
enabled,
base_url, base_url,
target_ref, target_ref,
target_key target_key
) VALUES (?, ?, ?, ?, ?) ) VALUES (?, ?, ?, ?, ?, ?)
RETURNING id; RETURNING id;

View file

@ -19,6 +19,7 @@ CREATE TABLE publish_targets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL, site_id INTEGER NOT NULL,
target_type TEXT NOT NULL, target_type TEXT NOT NULL,
enabled INT NOT NULL,
base_url TEXT NOT NULL, base_url TEXT NOT NULL,
target_ref TEXT NOT NULL, target_ref TEXT NOT NULL,
target_key TEXT NOT NULL target_key TEXT NOT NULL

View file

@ -11,7 +11,13 @@
<a class="nav-link active" aria-current="page" href="#">Posts</a> <a class="nav-link active" aria-current="page" href="#">Posts</a>
</li> </li>
</ul> </ul>
<form class="d-flex" role="search"> <form class="d-flex align-items-center" role="search">
<!--
<div class="spinner-border text-secondary me-2" role="status" title="Publishing...">
<span class="visually-hidden">Publishing...</span>
</div>
-->
<input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/> <input class="form-control me-2" type="search" placeholder="Search" aria-label="Search"/>
<button class="btn btn-outline-success" type="submit">Search</button> <button class="btn btn-outline-success" type="submit">Search</button>
</form> </form>

View file

@ -1,11 +1,11 @@
<main class="flex-grow-1 position-relative"> <main class="flex-grow-1 position-relative">
<form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2"> <form action="/sites/{{.site.ID}}/posts" method="post" class="container-fluid post-form p-2">
<input type="hidden" name="guid" value="{{ .guid }}"> <input type="hidden" name="guid" value="{{ .post.GUID }}">
<div class="mb-2"> <div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title"> <input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
</div> </div>
<div> <div>
<textarea name="body" class="form-control" rows="3"></textarea> <textarea name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
</div> </div>
<div> <div>
<input type="submit" class="btn btn-primary mt-2" value="Publish"> <input type="submit" class="btn btn-primary mt-2" value="Publish">

View file

@ -1 +1,17 @@
<h1>Posts go here</h1> <main class="container">
<div class="my-4">
<a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a>
</div>
{{ range $i, $p := .posts }}
{{ if gt $i 0 }}<hr>{{ end }}
<div class="my-4">
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
{{ $p.Body | markdown }}
<div class="mb-3">
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/edit"
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Edit</a>
</div>
</div>
{{ end }}
</main>