Added keyboard shortcuts for post editing.

This commit is contained in:
Leon Mika 2026-02-24 22:21:26 +11:00
parent 4f7058bf36
commit 44d35c6ccb
13 changed files with 215 additions and 28 deletions

View file

@ -0,0 +1,10 @@
---
id: Ed6vIR86gspx
title: ""
date: 2026-02-24T10:55:39Z
tags: []
slug: /2026/02/24/it-may-have
---
It may have been an issue with the call to air? Hmm. Will need to make sure to check that is working correctly.
I am now updating this. I am also updating this too.

View file

@ -0,0 +1,8 @@
---
id: bzAVD55SB2LE
title: ""
date: 2026-02-24T11:20:23Z
tags: []
slug: /2026/02/24/it-will-even
---
It will even do it for new posts.

View file

@ -0,0 +1,12 @@
---
id: MFHzBhJwJCQ3
title: ""
date: 2026-02-24T11:20:11Z
tags: []
slug: /2026/02/24/this-is-a
---
This is a new post, and will be saved as a draft.
It's still a draft. But the minute I publish it, it will be updated as an updated post. You see? I'm still writing in this.
But the minute I press enter, it will always publish.

View file

@ -0,0 +1,10 @@
---
id: -sU1lmmL7i56
title: ""
date: 2026-02-24T11:20:44Z
tags: []
slug: /2026/02/24/this-was-a
---
This was a draft.
But now it's a published post.

View file

@ -0,0 +1,79 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class PosteditController extends Controller {
static values = {
saveAction: String,
};
connect() {
console.log("connected");
}
async save(ev) {
ev.preventDefault();
try {
await this._postForm(this.saveActionValue);
showToast({
title: "💾 Post Saved",
body: (this.saveActionValue === "Save Draft") ? "Post saved as draft." : "Post updated.",
});
} catch (e) {
console.error(e);
showToast({
title: "❌ Error",
body: "Unable to save post. Please try again later.",
});
}
}
async publish(ev) {
ev.preventDefault();
try {
await this._postForm("Publish");
window.location.href = this.element.getAttribute("action");
} catch (e) {
console.error(e);
showToast({
title: "❌ Error",
body: "Unable to publish post. Please try again later.",
});
}
}
async _postForm(action) {
if (this._isPosting) {
return;
}
this._isPosting = true;
try {
const formData = new FormData(this.element);
let data = Object.fromEntries(formData.entries());
data = {...data, action: action || 'save'};
const response = await fetch(this.element.getAttribute("action"), {
method: 'POST',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
} finally {
this._isPosting = false;
}
}
}

View file

@ -1,5 +1,4 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class PostlistController extends Controller {

View file

@ -2,7 +2,9 @@ import { Application } from "@hotwired/stimulus";
import ToastController from "./controllers/toast";
import PostlistController from "./controllers/postlist";
import PosteditController from "./controllers/postedit";
window.Stimulus = Application.start()
Stimulus.register("toast", ToastController);
Stimulus.register("postlist", PostlistController);
Stimulus.register("postedit", PosteditController);

View file

@ -13,6 +13,7 @@ func AuthUser() func(c fiber.Ctx) error {
user := models.User{
ID: 1,
Username: "testuser",
TimeZone: "Australia/Melbourne",
}
c.Locals("user", user)

View file

@ -39,7 +39,8 @@ func (ph PostsHandler) Index(c fiber.Ctx) error {
func (ph PostsHandler) New(c fiber.Ctx) error {
p := models.Post{
GUID: models.NewNanoID(),
GUID: models.NewNanoID(),
State: models.StateDraft,
}
return c.Render("posts/edit", fiber.Map{
@ -77,7 +78,7 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
return err
}
post, err := ph.PostService.PublishPost(c.Context(), req)
post, err := ph.PostService.UpdatePost(c.Context(), req)
if err != nil {
return err
}

View file

@ -1,7 +1,31 @@
package models
import "time"
type User struct {
ID int64
Username string
PasswordHashed []byte
TimeZone string
}
func (u User) FormatTime(t time.Time) string {
if loc := getLocation(u.TimeZone); loc != nil {
return t.In(loc).Format("2006-01-02 15:04:05")
}
return t.Format("2006-01-02 15:04:05")
}
var loadedLocation = map[string]*time.Location{}
func getLocation(tz string) *time.Location {
if loc, ok := loadedLocation[tz]; ok {
return loc
}
loc, err := time.LoadLocation(tz)
if err != nil {
loc = time.Local
}
loadedLocation[tz] = loc
return loc
}

View file

@ -2,6 +2,7 @@ package posts
import (
"context"
"strings"
"time"
"lmika.dev/lmika/weiro/models"
@ -9,12 +10,13 @@ import (
)
type CreatePostParams struct {
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
GUID string `form:"guid" json:"guid"`
Title string `form:"title" json:"title"`
Body string `form:"body" json:"body"`
Action string `form:"action" json:"action"`
}
func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
func (s *Service) UpdatePost(ctx context.Context, params CreatePostParams) (*models.Post, error) {
site, ok := models.GetSite(ctx)
if !ok {
return nil, models.SiteRequiredError
@ -27,14 +29,28 @@ func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*mo
post.Title = params.Title
post.Body = params.Body
post.PublishedAt = time.Now()
post.UpdatedAt = time.Now()
post.Slug = post.BestSlug()
oldState := post.State
switch strings.ToLower(params.Action) {
case "publish":
post.State = models.StatePublished
post.PublishedAt = time.Now()
case "save draft":
post.State = models.StateDraft
post.PublishedAt = time.Time{}
default:
// Leave unchanged
}
if err := s.db.SavePost(ctx, post); err != nil {
return nil, err
}
s.publisher.Queue(site)
if oldState != post.State || post.State == models.StatePublished {
s.publisher.Queue(site)
}
return post, nil
}
@ -56,6 +72,7 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param
GUID: params.GUID,
Title: params.Title,
Body: params.Body,
State: models.StateDraft,
CreatedAt: time.Now(),
}
return post, nil

View file

@ -1,5 +1,9 @@
{{ $isPublished := ne .post.State 1 }}
<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"
data-controller="postedit"
data-action="keydown.meta+s->postedit#save keydown.meta+enter->postedit#publish"
data-postedit-save-action-value="{{ if $isPublished }}Update{{ else }}Save Draft{{ end }}">
<input type="hidden" name="guid" value="{{ .post.GUID }}">
<div class="mb-2">
<input type="text" name="title" class="form-control" placeholder="Title" value="{{ .post.Title }}">
@ -8,7 +12,12 @@
<textarea name="body" class="form-control" rows="3">{{.post.Body}}</textarea>
</div>
<div>
<input type="submit" class="btn btn-primary mt-2" value="Publish">
{{ if $isPublished }}
<input type="submit" name="action" class="btn btn-primary mt-2" value="Update">
{{ else }}
<input type="submit" name="action" class="btn btn-primary mt-2" value="Publish">
<input type="submit" name="action" class="btn btn-secondary mt-2" value="Save Draft">
{{ end }}
</div>
</form>
</main>

View file

@ -23,25 +23,40 @@
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
{{ $p.Body | markdown }}
{{ if $showingTrash }}
<div class="mb-3">
<a href="#" data-action="click->postlist#restorePost"
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Restore</a>
-
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Delete</a>
</div>
{{ else }}
<div class="mb-3">
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/"
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Edit</a>
-
<a href="#" data-action="click->postlist#deletePost"
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Trash</a>
</div>
{{ end }}
<div class="mb-3 d-flex align-items-center">
{{ if eq .State 1 }}
<span class="text-muted">{{ $.user.FormatTime .UpdatedAt }}</span> <span class="ms-3 badge text-primary-emphasis bg-primary-subtle border border-primary-subtle">Draft</span>
{{ else }}
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
{{ end }}
</div>
<div class="mb-3 d-flex align-items-center">
{{ if $showingTrash }}
<span>
<a href="#" data-action="click->postlist#restorePost" class="btn btn-outline-secondary btn-sm">Restore</a>
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
class="btn btn-outline-danger btn-sm">Delete</a>
</span>
{{ else }}
<span>
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/"
class="btn btn-outline-secondary btn-sm">Edit</a>
<a href="#" data-action="click->postlist#deletePost"
class="btn btn-outline-secondary btn-sm">Trash</a>
</span>
{{ end }}
</div>
</div>
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
</div>
{{ else }}
<div class="h4 m-3 text-center">
{{ if $showingTrash }}
<div class="position-absolute top-50 start-50 translate-middle">🗑️<br>Trash is empty.</div>
{{ else }}
<div class="position-absolute top-50 start-50 translate-middle">🌱<br>No posts yet. Better get writing!</div>
{{ end }}
</div>
{{ end }}
</main>