Have got soft and hard deleting

This commit is contained in:
Leon Mika 2026-02-23 21:18:34 +11:00
parent aef3bb6a1e
commit 3ea5823ca0
27 changed files with 588 additions and 55 deletions

View file

@ -0,0 +1,8 @@
---
id: X-fIs5JROC49
title: ""
date: 2026-02-23T10:12:47Z
tags: []
slug: /2026/02/23/another-post-to
---
Another post to delete.

View file

@ -0,0 +1,8 @@
---
id: hTF0-vhojyR7
title: ""
date: 2026-02-23T10:16:19Z
tags: []
slug: /2026/02/23/i-should-soft
---
I should soft delete.

View file

@ -0,0 +1,8 @@
---
id: EWhQUasFRLfJ
title: ""
date: 2026-02-23T10:12:00Z
tags: []
slug: /2026/02/23/this-is-to
---
This is to be deleted.

View file

@ -0,0 +1,77 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class PostlistController extends Controller {
static values = {
siteId: Number,
postId: Number,
nanoSummary: String,
};
async deletePost(ev) {
ev.preventDefault();
let isHardDelete = ev.params && ev.params.hardDelete;
if (isHardDelete) {
if (!confirm("Are you sure you want to delete this post?")) {
return;
}
}
try {
let deleteQuery = isHardDelete ? '?hard=true' : '';
this.element.remove();
await fetch(`/sites/${this.siteIdValue}/posts/${this.postIdValue}${deleteQuery}`, {
method: 'DELETE',
headers: { 'Accept': 'application/json' },
});
if (isHardDelete) {
showToast({
title: "🔥 Post Delete",
body: this.nanoSummaryValue,
});
} else {
showToast({
title: "🗑️ Sent To Trash",
body: this.nanoSummaryValue,
});
}
} catch (error) {
showToast({
title: "❌ Error",
body: "Failed to delete post. Please try again later.",
});
}
}
async restorePost(ev) {
ev.preventDefault();
try {
this.element.remove();
await fetch(`/sites/${this.siteIdValue}/posts/${this.postIdValue}`, {
method: 'PATCH',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'restore'
})
});
showToast({
title: "🗑️ Restored From Trash",
body: this.nanoSummaryValue,
});
} catch (error) {
showToast({
title: "❌ Error",
body: "Failed to rstore post. Please try again later.",
});
}
}
}

View file

@ -0,0 +1,24 @@
import { Toast } from 'bootstrap/dist/js/bootstrap.js';
import { Controller } from "@hotwired/stimulus"
export default class ToastController extends Controller {
static targets = ['title', 'body'];
initialize() {
this._toast = new Toast(this.element);
}
showToast(ev) {
let toastDetails = ev.detail;
if (!toastDetails) {
return;
}
this.titleTarget.innerText = toastDetails.title || "Title";
this.bodyTarget.innerText = toastDetails.body || "Body";
if (!this._toast.isShown()) {
this._toast.show();
}
}
}

8
assets/js/main.js Normal file
View file

@ -0,0 +1,8 @@
import { Application } from "@hotwired/stimulus";
import ToastController from "./controllers/toast";
import PostlistController from "./controllers/postlist";
window.Stimulus = Application.start()
Stimulus.register("toast", ToastController);
Stimulus.register("postlist", PostlistController);

View file

@ -0,0 +1,6 @@
export function showToast(details) {
let event = new CustomEvent('weiroToast', {
detail: details
});
window.dispatchEvent(event);
}

View file

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

35
handlers/accepts.go Normal file
View file

@ -0,0 +1,35 @@
package handlers
import (
"github.com/gofiber/fiber/v3"
)
type acceptor struct {
canAccept func(ctx fiber.Ctx) bool
acceptFn func(ctx fiber.Ctx) error
}
func accepts(ctx fiber.Ctx, acceptors ...acceptor) error {
for _, a := range acceptors {
if a.canAccept(ctx) {
return a.acceptFn(ctx)
}
}
return fiber.ErrNotFound
}
func json(fn func() any) acceptor {
return acceptor{
canAccept: func(ctx fiber.Ctx) bool { return ctx.AcceptsJSON() && !ctx.AcceptsHTML() },
acceptFn: func(ctx fiber.Ctx) error {
return ctx.Status(fiber.StatusOK).JSON(fn())
},
}
}
func html(fn func(ctx fiber.Ctx) error) acceptor {
return acceptor{
canAccept: func(ctx fiber.Ctx) bool { return ctx.AcceptsHTML() },
acceptFn: fn,
}
}

View file

@ -2,6 +2,7 @@ package handlers
import ( import (
"fmt" "fmt"
"log"
"strconv" "strconv"
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
@ -14,14 +15,26 @@ type PostsHandler struct {
} }
func (ph PostsHandler) Index(c fiber.Ctx) error { func (ph PostsHandler) Index(c fiber.Ctx) error {
posts, err := ph.PostService.ListPosts(c.Context()) var req struct {
Filter string `query:"filter"`
}
if err := c.Bind().Query(&req); err != nil {
return fiber.ErrBadRequest
}
posts, err := ph.PostService.ListPosts(c.Context(), req.Filter == "deleted")
if err != nil { if err != nil {
return err return err
} }
return accepts(c, json(func() any {
return posts
}), html(func(c fiber.Ctx) error {
return c.Render("posts/index", fiber.Map{ return c.Render("posts/index", fiber.Map{
"req": req,
"posts": posts, "posts": posts,
}) })
}))
} }
func (ph PostsHandler) New(c fiber.Ctx) error { func (ph PostsHandler) New(c fiber.Ctx) error {
@ -49,9 +62,13 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
return err return err
} }
return accepts(c, json(func() any {
return post
}), html(func(c fiber.Ctx) error {
return c.Render("posts/edit", fiber.Map{ return c.Render("posts/edit", fiber.Map{
"post": post, "post": post,
}) })
}))
} }
func (ph PostsHandler) Update(c fiber.Ctx) error { func (ph PostsHandler) Update(c fiber.Ctx) error {
@ -65,5 +82,77 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
return err return err
} }
return accepts(c, json(func() any {
// TODO: should be created if brand new
return post
}), html(func(c fiber.Ctx) error {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID)) return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
}))
}
func (ph PostsHandler) Patch(c fiber.Ctx) error {
log.Println("PATCH")
postIDStr := c.Params("postID")
if postIDStr == "" {
return fiber.ErrBadRequest
}
postID, err := strconv.ParseInt(postIDStr, 10, 64)
if err != nil {
return fiber.ErrBadRequest
}
var req struct {
Action string `json:"action"`
}
if err := c.Bind().Body(&req); err != nil {
return err
}
log.Println("Request")
switch req.Action {
case "restore":
if err := ph.PostService.RestorePost(c.Context(), postID); err != nil {
return err
}
default:
return fiber.ErrBadRequest
}
return accepts(c, json(func() any {
return struct{}{}
}), html(func(c fiber.Ctx) error {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts"))
}))
}
func (ph PostsHandler) Delete(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
}
var req struct {
Hard bool `query:"hard"`
}
if err := c.Bind().Query(&req); err != nil {
return err
}
if err := ph.PostService.DeletePost(c.Context(), postID, req.Hard); err != nil {
return err
}
return accepts(c, json(func() any {
return fiber.Map{}
}), html(func(c fiber.Ctx) error {
return c.Redirect().To("/sites")
}))
} }

View file

@ -42,6 +42,7 @@ func main() {
//} //}
fiberTemplate := fiber_html.New("./views", ".html") fiberTemplate := fiber_html.New("./views", ".html")
fiberTemplate.Funcmap["sub"] = func(x, y int) int { return x - y }
fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML { fiberTemplate.Funcmap["markdown"] = func() func(s string) template.HTML {
mdParser := goldmark.New( mdParser := goldmark.New(
goldmark.WithExtensions(extension.GFM), goldmark.WithExtensions(extension.GFM),
@ -67,8 +68,10 @@ 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.Get("/posts/:postID", ph.Edit)
siteGroup.Post("/posts", ph.Update) siteGroup.Post("/posts", ph.Update)
siteGroup.Patch("/posts/:postID", ph.Patch)
siteGroup.Delete("/posts/:postID", ph.Delete)
app.Get("/", func(c fiber.Ctx) error { app.Get("/", func(c fiber.Ctx) error {
return c.Redirect().To("/sites/1/posts") return c.Redirect().To("/sites/1/posts")

View file

@ -6,3 +6,4 @@ var UserRequiredError = errors.New("user required")
var PermissionError = errors.New("permission denied") var PermissionError = errors.New("permission denied")
var NotFoundError = errors.New("not found") var NotFoundError = errors.New("not found")
var SiteRequiredError = errors.New("site required") var SiteRequiredError = errors.New("site required")
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")

View file

@ -8,15 +8,36 @@ import (
"unicode" "unicode"
) )
const (
StatePublished = iota
StateDraft
)
type Post struct { type Post struct {
ID int64 ID int64 `json:"id"`
SiteID int64 SiteID int64 `json:"site_id"`
GUID string State int `json:"state"`
Title string GUID string `json:"guid"`
Body string Title string `json:"title"`
Slug string Body string `json:"body"`
CreatedAt time.Time Slug string `json:"slug"`
PublishedAt time.Time CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
DeletedAt time.Time `json:"deleted_at,omitempty"`
PublishedAt time.Time `json:"published_at,omitempty"`
}
func (p *Post) NanoSummary() string {
if p.Title != "" {
return p.Title
}
firstWords := firstNWords(p.Body, 7, wordForSummary)
if firstWords == "" {
firstWords = "(no content)"
} else if len(firstWords) < len(p.Body) {
return firstWords + "..."
}
return firstWords
} }
func (p *Post) BestSlug() string { func (p *Post) BestSlug() string {
@ -46,6 +67,10 @@ func (p *Post) BestSlug() string {
return fmt.Sprintf("/%s/%s", datePart, slugPath) return fmt.Sprintf("/%s/%s", datePart, slugPath)
} }
func wordForSummary(word string) string {
return word
}
func wordForSlug(word string) string { func wordForSlug(word string) string {
var sb strings.Builder var sb strings.Builder
for _, c := range word { for _, c := range word {

7
package-lock.json generated
View file

@ -5,6 +5,7 @@
"packages": { "packages": {
"": { "": {
"dependencies": { "dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0" "esbuild-sass-plugin": "^3.6.0"
}, },
@ -435,6 +436,12 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@hotwired/stimulus": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@hotwired/stimulus/-/stimulus-3.2.2.tgz",
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A==",
"license": "MIT"
},
"node_modules/@parcel/watcher": { "node_modules/@parcel/watcher": {
"version": "2.5.6", "version": "2.5.6",
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",

View file

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

View file

@ -7,12 +7,15 @@ package sqlgen
type Post struct { type Post struct {
ID int64 ID int64
SiteID int64 SiteID int64
State int64
Guid string Guid string
Title string Title string
Body string Body string
Slug string Slug string
CreatedAt int64 CreatedAt int64
UpdatedAt int64
PublishedAt int64 PublishedAt int64
DeletedAt int64
} }
type PublishTarget struct { type PublishTarget struct {

View file

@ -9,46 +9,73 @@ import (
"context" "context"
) )
const hardDeletePost = `-- name: HardDeletePost :exec
DELETE FROM posts WHERE id = ?
`
func (q *Queries) HardDeletePost(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, hardDeletePost, id)
return err
}
const insertPost = `-- name: InsertPost :one const insertPost = `-- name: InsertPost :one
INSERT INTO posts ( INSERT INTO posts (
site_id, site_id,
state,
guid, guid,
title, title,
body, body,
slug, slug,
created_at, created_at,
published_at updated_at,
) VALUES (?, ?, ?, ?, ?, ?, ?) published_at,
deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id RETURNING id
` `
type InsertPostParams struct { type InsertPostParams struct {
SiteID int64 SiteID int64
State int64
Guid string Guid string
Title string Title string
Body string Body string
Slug string Slug string
CreatedAt int64 CreatedAt int64
UpdatedAt int64
PublishedAt int64 PublishedAt int64
DeletedAt int64
} }
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) { func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertPost, row := q.db.QueryRowContext(ctx, insertPost,
arg.SiteID, arg.SiteID,
arg.State,
arg.Guid, arg.Guid,
arg.Title, arg.Title,
arg.Body, arg.Body,
arg.Slug, arg.Slug,
arg.CreatedAt, arg.CreatedAt,
arg.UpdatedAt,
arg.PublishedAt, arg.PublishedAt,
arg.DeletedAt,
) )
var id int64 var id int64
err := row.Scan(&id) err := row.Scan(&id)
return id, err return id, err
} }
const restorePost = `-- name: RestorePost :exec
UPDATE posts SET deleted_at = 0 WHERE id = ?
`
func (q *Queries) RestorePost(ctx context.Context, id int64) error {
_, err := q.db.ExecContext(ctx, restorePost, id)
return err
}
const selectPost = `-- name: SelectPost :one const selectPost = `-- name: SelectPost :one
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE id = ? LIMIT 1 SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE id = ? LIMIT 1
` `
func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) { func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
@ -57,18 +84,21 @@ func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.State,
&i.Guid, &i.Guid,
&i.Title, &i.Title,
&i.Body, &i.Body,
&i.Slug, &i.Slug,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt, &i.PublishedAt,
&i.DeletedAt,
) )
return i, err 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, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at FROM posts WHERE guid = ? LIMIT 1
` `
func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) { func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, error) {
@ -77,22 +107,37 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro
err := row.Scan( err := row.Scan(
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.State,
&i.Guid, &i.Guid,
&i.Title, &i.Title,
&i.Body, &i.Body,
&i.Slug, &i.Slug,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt, &i.PublishedAt,
&i.DeletedAt,
) )
return i, err return i, err
} }
const selectPostsOfSite = `-- name: SelectPostsOfSite :many const selectPostsOfSite = `-- name: SelectPostsOfSite :many
SELECT id, site_id, guid, title, body, slug, created_at, published_at FROM posts WHERE site_id = ? ORDER BY created_at DESC LIMIT 10 SELECT id, site_id, state, guid, title, body, slug, created_at, updated_at, published_at, deleted_at
FROM posts
WHERE site_id = ? AND (
CASE CAST (?2 AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
) ORDER BY created_at DESC LIMIT 10
` `
func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post, error) { type SelectPostsOfSiteParams struct {
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, siteID) SiteID int64
PostFilter string
}
func (q *Queries) SelectPostsOfSite(ctx context.Context, arg SelectPostsOfSiteParams) ([]Post, error) {
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, arg.SiteID, arg.PostFilter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -103,12 +148,15 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
if err := rows.Scan( if err := rows.Scan(
&i.ID, &i.ID,
&i.SiteID, &i.SiteID,
&i.State,
&i.Guid, &i.Guid,
&i.Title, &i.Title,
&i.Body, &i.Body,
&i.Slug, &i.Slug,
&i.CreatedAt, &i.CreatedAt,
&i.UpdatedAt,
&i.PublishedAt, &i.PublishedAt,
&i.DeletedAt,
); err != nil { ); err != nil {
return nil, err return nil, err
} }
@ -123,29 +171,52 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
return items, nil return items, nil
} }
const softDeletePost = `-- name: SoftDeletePost :exec
UPDATE posts SET deleted_at = ? WHERE id = ?
`
type SoftDeletePostParams struct {
DeletedAt int64
ID int64
}
func (q *Queries) SoftDeletePost(ctx context.Context, arg SoftDeletePostParams) error {
_, err := q.db.ExecContext(ctx, softDeletePost, arg.DeletedAt, arg.ID)
return err
}
const updatePost = `-- name: UpdatePost :exec const updatePost = `-- name: UpdatePost :exec
UPDATE posts SET UPDATE posts SET
title = ?, title = ?,
state = ?,
body = ?, body = ?,
slug = ?, slug = ?,
published_at = ? updated_at = ?,
published_at = ?,
deleted_at = ?
WHERE id = ? WHERE id = ?
` `
type UpdatePostParams struct { type UpdatePostParams struct {
Title string Title string
State int64
Body string Body string
Slug string Slug string
UpdatedAt int64
PublishedAt int64 PublishedAt int64
DeletedAt int64
ID int64 ID int64
} }
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error { func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
_, err := q.db.ExecContext(ctx, updatePost, _, err := q.db.ExecContext(ctx, updatePost,
arg.Title, arg.Title,
arg.State,
arg.Body, arg.Body,
arg.Slug, arg.Slug,
arg.UpdatedAt,
arg.PublishedAt, arg.PublishedAt,
arg.DeletedAt,
arg.ID, arg.ID,
) )
return err return err

View file

@ -8,8 +8,16 @@ import (
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
) )
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) { func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) {
rows, err := db.queries.SelectPostsOfSite(ctx, siteID) var filter = ""
if showDeleted {
filter = "deleted"
}
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
SiteID: siteID,
PostFilter: filter,
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -43,12 +51,15 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
if post.ID == 0 { if post.ID == 0 {
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{ newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
SiteID: post.SiteID, SiteID: post.SiteID,
State: int64(post.State),
Guid: post.GUID, Guid: post.GUID,
Title: post.Title, Title: post.Title,
Body: post.Body, Body: post.Body,
Slug: post.Slug, Slug: post.Slug,
CreatedAt: post.CreatedAt.Unix(), CreatedAt: timeToInt(post.CreatedAt),
PublishedAt: post.PublishedAt.Unix(), UpdatedAt: timeToInt(post.UpdatedAt),
PublishedAt: timeToInt(post.PublishedAt),
DeletedAt: timeToInt(post.DeletedAt),
}) })
if err != nil { if err != nil {
return err return err
@ -59,10 +70,13 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{ return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
ID: post.ID, ID: post.ID,
State: int64(post.State),
Title: post.Title, Title: post.Title,
Body: post.Body, Body: post.Body,
Slug: post.Slug, Slug: post.Slug,
PublishedAt: post.PublishedAt.Unix(), UpdatedAt: timeToInt(post.UpdatedAt),
PublishedAt: timeToInt(post.PublishedAt),
DeletedAt: timeToInt(post.DeletedAt),
}) })
} }
@ -70,11 +84,21 @@ func dbPostToPost(row sqlgen.Post) *models.Post {
return &models.Post{ return &models.Post{
ID: row.ID, ID: row.ID,
SiteID: row.SiteID, SiteID: row.SiteID,
State: int(row.State),
GUID: row.Guid, GUID: row.Guid,
Title: row.Title, Title: row.Title,
Body: row.Body, Body: row.Body,
Slug: row.Slug, Slug: row.Slug,
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(), CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
UpdatedAt: time.Unix(row.UpdatedAt, 0).UTC(),
PublishedAt: time.Unix(row.PublishedAt, 0).UTC(), PublishedAt: time.Unix(row.PublishedAt, 0).UTC(),
DeletedAt: time.Unix(row.DeletedAt, 0).UTC(),
} }
} }
func timeToInt(t time.Time) int64 {
if t.IsZero() {
return 0
}
return t.Unix()
}

View file

@ -3,6 +3,7 @@ package db
import ( import (
"context" "context"
"database/sql" "database/sql"
"time"
"github.com/Southclaws/fault" "github.com/Southclaws/fault"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen" "lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -38,3 +39,18 @@ func New(dbFile string) (*Provider, error) {
func (db *Provider) Close() error { func (db *Provider) Close() error {
return db.drvr.Close() return db.drvr.Close()
} }
func (db *Provider) SoftDeletePost(ctx context.Context, postID int64) error {
return db.queries.SoftDeletePost(ctx, sqlgen.SoftDeletePostParams{
DeletedAt: time.Now().Unix(),
ID: postID,
})
}
func (db *Provider) HardDeletePost(ctx context.Context, postID int64) error {
return db.queries.HardDeletePost(ctx, postID)
}
func (db *Provider) RestorePost(ctx context.Context, postID int64) error {
return db.queries.RestorePost(ctx, postID)
}

46
services/posts/delete.go Normal file
View file

@ -0,0 +1,46 @@
package posts
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
)
const (
deleteDebounce = 2 * time.Second
)
func (s *Service) DeletePost(ctx context.Context, pid int64, hardDelete bool) error {
site, ok := models.GetSite(ctx)
if !ok {
return models.SiteRequiredError
}
post, err := s.db.SelectPost(ctx, pid)
if err != nil {
return err
} else if post.SiteID != site.ID {
return models.NotFoundError
}
if hardDelete && post.DeletedAt.Unix() > 0 {
delta := time.Now().Sub(post.DeletedAt)
if delta < deleteDebounce {
return models.DeleteDebounceError
}
return s.db.HardDeletePost(ctx, post.ID)
}
return s.db.SoftDeletePost(ctx, post.ID)
}
func (s *Service) RestorePost(ctx context.Context, pid int64) error {
post, err := s.db.SelectPost(ctx, pid)
if err != nil {
return err
}
return s.db.RestorePost(ctx, post.ID)
}

View file

@ -6,13 +6,13 @@ import (
"lmika.dev/lmika/weiro/models" "lmika.dev/lmika/weiro/models"
) )
func (s *Service) ListPosts(ctx context.Context) ([]*models.Post, error) { func (s *Service) ListPosts(ctx context.Context, showDeleted bool) ([]*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
} }
posts, err := s.db.SelectPostsOfSite(ctx, site.ID) posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted)
if err != nil { if err != nil {
return nil, err return nil, err
} }

View file

@ -35,7 +35,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
} }
// Fetch all content of site // Fetch all content of site
posts, err := p.db.SelectPostsOfSite(ctx, site.ID) posts, err := p.db.SelectPostsOfSite(ctx, site.ID, false)
if err != nil { if err != nil {
return err return err
} }

View file

@ -1,5 +1,12 @@
-- 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 = ? AND (
CASE CAST (sqlc.arg(post_filter) AS TEXT)
WHEN 'deleted' THEN deleted_at > 0
ELSE deleted_at = 0
END
) ORDER BY created_at DESC LIMIT 10;
-- name: SelectPost :one -- name: SelectPost :one
SELECT * FROM posts WHERE id = ? LIMIT 1; SELECT * FROM posts WHERE id = ? LIMIT 1;
@ -10,19 +17,34 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1;
-- name: InsertPost :one -- name: InsertPost :one
INSERT INTO posts ( INSERT INTO posts (
site_id, site_id,
state,
guid, guid,
title, title,
body, body,
slug, slug,
created_at, created_at,
published_at updated_at,
) VALUES (?, ?, ?, ?, ?, ?, ?) published_at,
deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
RETURNING id; RETURNING id;
-- name: UpdatePost :exec -- name: UpdatePost :exec
UPDATE posts SET UPDATE posts SET
title = ?, title = ?,
state = ?,
body = ?, body = ?,
slug = ?, slug = ?,
published_at = ? updated_at = ?,
published_at = ?,
deleted_at = ?
WHERE id = ?; WHERE id = ?;
-- name: SoftDeletePost :exec
UPDATE posts SET deleted_at = ? WHERE id = ?;
-- name: RestorePost :exec
UPDATE posts SET deleted_at = 0 WHERE id = ?;
-- name: HardDeletePost :exec
DELETE FROM posts WHERE id = ?;

View file

@ -29,12 +29,15 @@ CREATE INDEX idx_publish_targets_site ON publish_targets (site_id);
CREATE TABLE posts ( CREATE TABLE posts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
site_id INTEGER NOT NULL, site_id INTEGER NOT NULL,
state INTEGER NOT NULL,
guid TEXT NOT NULL, guid TEXT NOT NULL,
title TEXT NOT NULL, title TEXT NOT NULL,
body TEXT NOT NULL, body TEXT NOT NULL,
slug TEXT NOT NULL, slug TEXT NOT NULL,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
published_at INTEGER NOT NULL updated_at INTEGER NOT NULL,
published_at INTEGER NOT NULL,
deleted_at INTEGER NOT NULL
); );
CREATE INDEX idx_post_site ON posts (site_id); CREATE INDEX idx_post_site ON posts (site_id);
CREATE UNIQUE INDEX idx_post_guid ON posts (guid); CREATE UNIQUE INDEX idx_post_guid ON posts (guid);

8
views/_common/toast.html Normal file
View file

@ -0,0 +1,8 @@
<div class="toast position-fixed bottom-0 end-0" role="alert" aria-live="assertive" aria-atomic="true"
data-controller="toast" data-action="weiroToast@window->toast#showToast">
<div class="toast-header">
<strong class="me-auto" data-toast-target="title">Title</strong>
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
</div>
<div class="toast-body" data-toast-target="body">Body</div>
</div>

View file

@ -5,10 +5,13 @@
<title>Title</title> <title>Title</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/assets/main.css"> <link rel="stylesheet" href="/static/assets/main.css">
<script src="/static/assets/main.js" type="module"></script>
</head> </head>
<body class="min-vh-100 d-flex flex-column"> <body class="min-vh-100 d-flex flex-column">
{{ template "_common/nav" . }} {{ template "_common/nav" . }}
{{ embed }} {{ embed }}
{{ template "_common/toast" . }}
</body> </body>
</html> </html>

View file

@ -1,17 +1,47 @@
{{ $showingTrash := eq .req.Filter "deleted" }}
<main class="container"> <main class="container">
<div class="my-4"> <div class="my-4 d-flex justify-content-between align-items-baseline">
<a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a> <a href="/sites/{{ .site.ID }}/posts/new" class="btn btn-success">New Post</a>
<div>
<div class="btn-group" role="group" aria-label="First group">
{{ if $showingTrash }}
<a href="/sites/{{ .site.ID }}/posts" type="button" class="btn btn-secondary" title="Trash">🗑️</a>
{{ else }}
<a href="/sites/{{ .site.ID }}/posts?filter=deleted" type="button" class="btn btn-outline-secondary" title="Trash">🗑️</a>
{{ end }}
</div>
</div>
</div> </div>
{{ range $i, $p := .posts }} {{ range $i, $p := .posts }}
{{ if gt $i 0 }}<hr>{{ end }} <div data-controller="postlist"
data-postlist-site-id-value="{{ $p.SiteID }}"
data-postlist-post-id-value="{{ $p.ID }}"
data-postlist-nano-summary-value="{{ $p.NanoSummary }}">
<div class="my-4"> <div class="my-4">
{{ 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">
<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"> <div class="mb-3">
<a href="/sites/{{ $.site.ID }}/posts/{{ $p.ID }}/edit" <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> 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">Delete</a>
</div> </div>
{{ end }}
</div>
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
</div> </div>
{{ end }} {{ end }}
</main> </main>