Added keyboard shortcuts for post editing.
This commit is contained in:
parent
4f7058bf36
commit
44d35c6ccb
10
_test-site/posts/2026/02/24-it-may-have.md
Normal file
10
_test-site/posts/2026/02/24-it-may-have.md
Normal 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.
|
||||||
8
_test-site/posts/2026/02/24-it-will-even.md
Normal file
8
_test-site/posts/2026/02/24-it-will-even.md
Normal 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.
|
||||||
12
_test-site/posts/2026/02/24-this-is-a.md
Normal file
12
_test-site/posts/2026/02/24-this-is-a.md
Normal 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.
|
||||||
10
_test-site/posts/2026/02/24-this-was-a.md
Normal file
10
_test-site/posts/2026/02/24-this-was-a.md
Normal 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.
|
||||||
79
assets/js/controllers/postedit.js
Normal file
79
assets/js/controllers/postedit.js
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { Controller } from "@hotwired/stimulus"
|
import { Controller } from "@hotwired/stimulus"
|
||||||
|
|
||||||
import { showToast } from "../services/toast";
|
import { showToast } from "../services/toast";
|
||||||
|
|
||||||
export default class PostlistController extends Controller {
|
export default class PostlistController extends Controller {
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,9 @@ import { Application } from "@hotwired/stimulus";
|
||||||
|
|
||||||
import ToastController from "./controllers/toast";
|
import ToastController from "./controllers/toast";
|
||||||
import PostlistController from "./controllers/postlist";
|
import PostlistController from "./controllers/postlist";
|
||||||
|
import PosteditController from "./controllers/postedit";
|
||||||
|
|
||||||
window.Stimulus = Application.start()
|
window.Stimulus = Application.start()
|
||||||
Stimulus.register("toast", ToastController);
|
Stimulus.register("toast", ToastController);
|
||||||
Stimulus.register("postlist", PostlistController);
|
Stimulus.register("postlist", PostlistController);
|
||||||
|
Stimulus.register("postedit", PosteditController);
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ func AuthUser() func(c fiber.Ctx) error {
|
||||||
user := models.User{
|
user := models.User{
|
||||||
ID: 1,
|
ID: 1,
|
||||||
Username: "testuser",
|
Username: "testuser",
|
||||||
|
TimeZone: "Australia/Melbourne",
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Locals("user", user)
|
c.Locals("user", user)
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,8 @@ func (ph PostsHandler) Index(c fiber.Ctx) error {
|
||||||
|
|
||||||
func (ph PostsHandler) New(c fiber.Ctx) error {
|
func (ph PostsHandler) New(c fiber.Ctx) error {
|
||||||
p := models.Post{
|
p := models.Post{
|
||||||
GUID: models.NewNanoID(),
|
GUID: models.NewNanoID(),
|
||||||
|
State: models.StateDraft,
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Render("posts/edit", fiber.Map{
|
return c.Render("posts/edit", fiber.Map{
|
||||||
|
|
@ -77,7 +78,7 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
post, err := ph.PostService.PublishPost(c.Context(), req)
|
post, err := ph.PostService.UpdatePost(c.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,31 @@
|
||||||
package models
|
package models
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64
|
ID int64
|
||||||
Username string
|
Username string
|
||||||
PasswordHashed []byte
|
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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package posts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"lmika.dev/lmika/weiro/models"
|
"lmika.dev/lmika/weiro/models"
|
||||||
|
|
@ -9,12 +10,13 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type CreatePostParams struct {
|
type CreatePostParams struct {
|
||||||
GUID string `form:"guid" json:"guid"`
|
GUID string `form:"guid" json:"guid"`
|
||||||
Title string `form:"title" json:"title"`
|
Title string `form:"title" json:"title"`
|
||||||
Body string `form:"body" json:"body"`
|
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)
|
site, ok := models.GetSite(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, models.SiteRequiredError
|
return nil, models.SiteRequiredError
|
||||||
|
|
@ -27,14 +29,28 @@ func (s *Service) PublishPost(ctx context.Context, params CreatePostParams) (*mo
|
||||||
|
|
||||||
post.Title = params.Title
|
post.Title = params.Title
|
||||||
post.Body = params.Body
|
post.Body = params.Body
|
||||||
post.PublishedAt = time.Now()
|
post.UpdatedAt = time.Now()
|
||||||
post.Slug = post.BestSlug()
|
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 {
|
if err := s.db.SavePost(ctx, post); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
s.publisher.Queue(site)
|
if oldState != post.State || post.State == models.StatePublished {
|
||||||
|
s.publisher.Queue(site)
|
||||||
|
}
|
||||||
|
|
||||||
return post, nil
|
return post, nil
|
||||||
}
|
}
|
||||||
|
|
@ -56,6 +72,7 @@ func (s *Service) fetchOrCreatePost(ctx context.Context, site models.Site, param
|
||||||
GUID: params.GUID,
|
GUID: params.GUID,
|
||||||
Title: params.Title,
|
Title: params.Title,
|
||||||
Body: params.Body,
|
Body: params.Body,
|
||||||
|
State: models.StateDraft,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
return post, nil
|
return post, nil
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
|
{{ $isPublished := ne .post.State 1 }}
|
||||||
<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"
|
||||||
|
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 }}">
|
<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" value="{{ .post.Title }}">
|
<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>
|
<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">
|
{{ 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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</main>
|
</main>
|
||||||
|
|
@ -23,25 +23,40 @@
|
||||||
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
{{ if $p.Title }}<h4 class="mb-3">{{ $p.Title }}</h4>{{ end }}
|
||||||
{{ $p.Body | markdown }}
|
{{ $p.Body | markdown }}
|
||||||
|
|
||||||
{{ if $showingTrash }}
|
<div class="mb-3 d-flex align-items-center">
|
||||||
<div class="mb-3">
|
{{ if eq .State 1 }}
|
||||||
<a href="#" data-action="click->postlist#restorePost"
|
<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>
|
||||||
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Restore</a>
|
{{ else }}
|
||||||
-
|
<span class="text-muted">{{ $.user.FormatTime .PublishedAt }}</span>
|
||||||
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
|
{{ end }}
|
||||||
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Delete</a>
|
</div>
|
||||||
</div>
|
|
||||||
{{ else }}
|
<div class="mb-3 d-flex align-items-center">
|
||||||
<div class="mb-3">
|
{{ if $showingTrash }}
|
||||||
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/"
|
<span>
|
||||||
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#restorePost" class="btn btn-outline-secondary btn-sm">Restore</a>
|
||||||
-
|
<a href="#" data-action="click->postlist#deletePost" data-postlist-hard-delete-param="true"
|
||||||
<a href="#" data-action="click->postlist#deletePost"
|
class="btn btn-outline-danger btn-sm">Delete</a>
|
||||||
class="link-secondary link-offset-2 link-underline link-underline-opacity-0 link-underline-opacity-75-hover">Trash</a>
|
</span>
|
||||||
</div>
|
{{ else }}
|
||||||
{{ end }}
|
<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>
|
</div>
|
||||||
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
|
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
|
||||||
</div>
|
</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 }}
|
{{ end }}
|
||||||
</main>
|
</main>
|
||||||
Loading…
Reference in a new issue