Have got soft and hard deleting
This commit is contained in:
parent
aef3bb6a1e
commit
3ea5823ca0
8
_test-site/posts/2026/02/23-another-post-to.md
Normal file
8
_test-site/posts/2026/02/23-another-post-to.md
Normal 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.
|
||||
8
_test-site/posts/2026/02/23-i-should-soft.md
Normal file
8
_test-site/posts/2026/02/23-i-should-soft.md
Normal 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.
|
||||
8
_test-site/posts/2026/02/23-this-is-to.md
Normal file
8
_test-site/posts/2026/02/23-this-is-to.md
Normal 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.
|
||||
77
assets/js/controllers/postlist.js
Normal file
77
assets/js/controllers/postlist.js
Normal 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.",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
24
assets/js/controllers/toast.js
Normal file
24
assets/js/controllers/toast.js
Normal 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
8
assets/js/main.js
Normal 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);
|
||||
6
assets/js/services/toast.js
Normal file
6
assets/js/services/toast.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function showToast(details) {
|
||||
let event = new CustomEvent('weiroToast', {
|
||||
detail: details
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
}
|
||||
19
esbuild.mjs
19
esbuild.mjs
|
|
@ -1,9 +1,16 @@
|
|||
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',
|
||||
});
|
||||
await Promise.all([
|
||||
esbuild.build({
|
||||
entryPoints: ['./assets/css/main.scss'],
|
||||
bundle: true,
|
||||
plugins: [sassPlugin()],
|
||||
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
35
handlers/accepts.go
Normal 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,
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package handlers
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
|
|
@ -14,14 +15,26 @@ type PostsHandler struct {
|
|||
}
|
||||
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
||||
return c.Render("posts/index", fiber.Map{
|
||||
"posts": posts,
|
||||
})
|
||||
return accepts(c, json(func() any {
|
||||
return posts
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
return c.Render("posts/index", fiber.Map{
|
||||
"req": req,
|
||||
"posts": posts,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (ph PostsHandler) New(c fiber.Ctx) error {
|
||||
|
|
@ -49,9 +62,13 @@ func (ph PostsHandler) Edit(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return c.Render("posts/edit", fiber.Map{
|
||||
"post": post,
|
||||
})
|
||||
return accepts(c, json(func() any {
|
||||
return post
|
||||
}), html(func(c fiber.Ctx) error {
|
||||
return c.Render("posts/edit", fiber.Map{
|
||||
"post": post,
|
||||
})
|
||||
}))
|
||||
}
|
||||
|
||||
func (ph PostsHandler) Update(c fiber.Ctx) error {
|
||||
|
|
@ -65,5 +82,77 @@ func (ph PostsHandler) Update(c fiber.Ctx) error {
|
|||
return err
|
||||
}
|
||||
|
||||
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", post.SiteID))
|
||||
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))
|
||||
}))
|
||||
}
|
||||
|
||||
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")
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
5
main.go
5
main.go
|
|
@ -42,6 +42,7 @@ func main() {
|
|||
//}
|
||||
|
||||
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 {
|
||||
mdParser := goldmark.New(
|
||||
goldmark.WithExtensions(extension.GFM),
|
||||
|
|
@ -67,8 +68,10 @@ func main() {
|
|||
|
||||
siteGroup.Get("/posts", ph.Index)
|
||||
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.Patch("/posts/:postID", ph.Patch)
|
||||
siteGroup.Delete("/posts/:postID", ph.Delete)
|
||||
|
||||
app.Get("/", func(c fiber.Ctx) error {
|
||||
return c.Redirect().To("/sites/1/posts")
|
||||
|
|
|
|||
|
|
@ -6,3 +6,4 @@ var UserRequiredError = errors.New("user required")
|
|||
var PermissionError = errors.New("permission denied")
|
||||
var NotFoundError = errors.New("not found")
|
||||
var SiteRequiredError = errors.New("site required")
|
||||
var DeleteDebounceError = errors.New("permanent delete too soon, try again in a few seconds")
|
||||
|
|
|
|||
|
|
@ -8,15 +8,36 @@ import (
|
|||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
StatePublished = iota
|
||||
StateDraft
|
||||
)
|
||||
|
||||
type Post struct {
|
||||
ID int64
|
||||
SiteID int64
|
||||
GUID string
|
||||
Title string
|
||||
Body string
|
||||
Slug string
|
||||
CreatedAt time.Time
|
||||
PublishedAt time.Time
|
||||
ID int64 `json:"id"`
|
||||
SiteID int64 `json:"site_id"`
|
||||
State int `json:"state"`
|
||||
GUID string `json:"guid"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Slug string `json:"slug"`
|
||||
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 {
|
||||
|
|
@ -46,6 +67,10 @@ func (p *Post) BestSlug() string {
|
|||
return fmt.Sprintf("/%s/%s", datePart, slugPath)
|
||||
}
|
||||
|
||||
func wordForSummary(word string) string {
|
||||
return word
|
||||
}
|
||||
|
||||
func wordForSlug(word string) string {
|
||||
var sb strings.Builder
|
||||
for _, c := range word {
|
||||
|
|
|
|||
7
package-lock.json
generated
7
package-lock.json
generated
|
|
@ -5,6 +5,7 @@
|
|||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"bootstrap": "^5.3.8",
|
||||
"esbuild-sass-plugin": "^3.6.0"
|
||||
},
|
||||
|
|
@ -435,6 +436,12 @@
|
|||
"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": {
|
||||
"version": "2.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
"esbuild": "0.27.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hotwired/stimulus": "^3.2.2",
|
||||
"bootstrap": "^5.3.8",
|
||||
"esbuild-sass-plugin": "^3.6.0"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,12 +7,15 @@ package sqlgen
|
|||
type Post struct {
|
||||
ID int64
|
||||
SiteID int64
|
||||
State int64
|
||||
Guid string
|
||||
Title string
|
||||
Body string
|
||||
Slug string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
PublishedAt int64
|
||||
DeletedAt int64
|
||||
}
|
||||
|
||||
type PublishTarget struct {
|
||||
|
|
|
|||
|
|
@ -9,46 +9,73 @@ import (
|
|||
"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
|
||||
INSERT INTO posts (
|
||||
site_id,
|
||||
state,
|
||||
guid,
|
||||
title,
|
||||
body,
|
||||
slug,
|
||||
created_at,
|
||||
published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
updated_at,
|
||||
published_at,
|
||||
deleted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id
|
||||
`
|
||||
|
||||
type InsertPostParams struct {
|
||||
SiteID int64
|
||||
State int64
|
||||
Guid string
|
||||
Title string
|
||||
Body string
|
||||
Slug string
|
||||
CreatedAt int64
|
||||
UpdatedAt int64
|
||||
PublishedAt int64
|
||||
DeletedAt int64
|
||||
}
|
||||
|
||||
func (q *Queries) InsertPost(ctx context.Context, arg InsertPostParams) (int64, error) {
|
||||
row := q.db.QueryRowContext(ctx, insertPost,
|
||||
arg.SiteID,
|
||||
arg.State,
|
||||
arg.Guid,
|
||||
arg.Title,
|
||||
arg.Body,
|
||||
arg.Slug,
|
||||
arg.CreatedAt,
|
||||
arg.UpdatedAt,
|
||||
arg.PublishedAt,
|
||||
arg.DeletedAt,
|
||||
)
|
||||
var id int64
|
||||
err := row.Scan(&id)
|
||||
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
|
||||
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) {
|
||||
|
|
@ -57,18 +84,21 @@ func (q *Queries) SelectPost(ctx context.Context, id int64) (Post, error) {
|
|||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.State,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Slug,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -77,22 +107,37 @@ func (q *Queries) SelectPostByGUID(ctx context.Context, guid string) (Post, erro
|
|||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.State,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Slug,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishedAt,
|
||||
&i.DeletedAt,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
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) {
|
||||
rows, err := q.db.QueryContext(ctx, selectPostsOfSite, siteID)
|
||||
type SelectPostsOfSiteParams struct {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -103,12 +148,15 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
|
|||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.SiteID,
|
||||
&i.State,
|
||||
&i.Guid,
|
||||
&i.Title,
|
||||
&i.Body,
|
||||
&i.Slug,
|
||||
&i.CreatedAt,
|
||||
&i.UpdatedAt,
|
||||
&i.PublishedAt,
|
||||
&i.DeletedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -123,29 +171,52 @@ func (q *Queries) SelectPostsOfSite(ctx context.Context, siteID int64) ([]Post,
|
|||
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
|
||||
UPDATE posts SET
|
||||
title = ?,
|
||||
state = ?,
|
||||
body = ?,
|
||||
slug = ?,
|
||||
published_at = ?
|
||||
updated_at = ?,
|
||||
published_at = ?,
|
||||
deleted_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
type UpdatePostParams struct {
|
||||
Title string
|
||||
State int64
|
||||
Body string
|
||||
Slug string
|
||||
UpdatedAt int64
|
||||
PublishedAt int64
|
||||
DeletedAt int64
|
||||
ID int64
|
||||
}
|
||||
|
||||
func (q *Queries) UpdatePost(ctx context.Context, arg UpdatePostParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updatePost,
|
||||
arg.Title,
|
||||
arg.State,
|
||||
arg.Body,
|
||||
arg.Slug,
|
||||
arg.UpdatedAt,
|
||||
arg.PublishedAt,
|
||||
arg.DeletedAt,
|
||||
arg.ID,
|
||||
)
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -8,8 +8,16 @@ import (
|
|||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||
)
|
||||
|
||||
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64) ([]*models.Post, error) {
|
||||
rows, err := db.queries.SelectPostsOfSite(ctx, siteID)
|
||||
func (db *Provider) SelectPostsOfSite(ctx context.Context, siteID int64, showDeleted bool) ([]*models.Post, error) {
|
||||
var filter = ""
|
||||
if showDeleted {
|
||||
filter = "deleted"
|
||||
}
|
||||
|
||||
rows, err := db.queries.SelectPostsOfSite(ctx, sqlgen.SelectPostsOfSiteParams{
|
||||
SiteID: siteID,
|
||||
PostFilter: filter,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -43,12 +51,15 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
|
|||
if post.ID == 0 {
|
||||
newID, err := db.queries.InsertPost(ctx, sqlgen.InsertPostParams{
|
||||
SiteID: post.SiteID,
|
||||
State: int64(post.State),
|
||||
Guid: post.GUID,
|
||||
Title: post.Title,
|
||||
Body: post.Body,
|
||||
Slug: post.Slug,
|
||||
CreatedAt: post.CreatedAt.Unix(),
|
||||
PublishedAt: post.PublishedAt.Unix(),
|
||||
CreatedAt: timeToInt(post.CreatedAt),
|
||||
UpdatedAt: timeToInt(post.UpdatedAt),
|
||||
PublishedAt: timeToInt(post.PublishedAt),
|
||||
DeletedAt: timeToInt(post.DeletedAt),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -59,10 +70,13 @@ func (db *Provider) SavePost(ctx context.Context, post *models.Post) error {
|
|||
|
||||
return db.queries.UpdatePost(ctx, sqlgen.UpdatePostParams{
|
||||
ID: post.ID,
|
||||
State: int64(post.State),
|
||||
Title: post.Title,
|
||||
Body: post.Body,
|
||||
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{
|
||||
ID: row.ID,
|
||||
SiteID: row.SiteID,
|
||||
State: int(row.State),
|
||||
GUID: row.Guid,
|
||||
Title: row.Title,
|
||||
Body: row.Body,
|
||||
Slug: row.Slug,
|
||||
CreatedAt: time.Unix(row.CreatedAt, 0).UTC(),
|
||||
UpdatedAt: time.Unix(row.UpdatedAt, 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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package db
|
|||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/Southclaws/fault"
|
||||
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
|
||||
|
|
@ -38,3 +39,18 @@ func New(dbFile string) (*Provider, error) {
|
|||
func (db *Provider) Close() error {
|
||||
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
46
services/posts/delete.go
Normal 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)
|
||||
}
|
||||
|
|
@ -6,13 +6,13 @@ import (
|
|||
"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)
|
||||
if !ok {
|
||||
return nil, models.SiteRequiredError
|
||||
}
|
||||
|
||||
posts, err := s.db.SelectPostsOfSite(ctx, site.ID)
|
||||
posts, err := s.db.SelectPostsOfSite(ctx, site.ID, showDeleted)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ func (p *Publisher) Publish(ctx context.Context, site models.Site) error {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
-- 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
|
||||
SELECT * FROM posts WHERE id = ? LIMIT 1;
|
||||
|
|
@ -10,19 +17,34 @@ SELECT * FROM posts WHERE guid = ? LIMIT 1;
|
|||
-- name: InsertPost :one
|
||||
INSERT INTO posts (
|
||||
site_id,
|
||||
state,
|
||||
guid,
|
||||
title,
|
||||
body,
|
||||
slug,
|
||||
created_at,
|
||||
published_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
updated_at,
|
||||
published_at,
|
||||
deleted_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
RETURNING id;
|
||||
|
||||
-- name: UpdatePost :exec
|
||||
UPDATE posts SET
|
||||
title = ?,
|
||||
state = ?,
|
||||
body = ?,
|
||||
slug = ?,
|
||||
published_at = ?
|
||||
updated_at = ?,
|
||||
published_at = ?,
|
||||
deleted_at = ?
|
||||
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 = ?;
|
||||
|
|
@ -18,8 +18,8 @@ CREATE INDEX idx_site_owner ON sites (owner_id);
|
|||
CREATE TABLE publish_targets (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
enabled INT NOT NULL,
|
||||
target_type TEXT NOT NULL,
|
||||
enabled INT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL,
|
||||
target_key TEXT NOT NULL
|
||||
|
|
@ -29,12 +29,15 @@ CREATE INDEX idx_publish_targets_site ON publish_targets (site_id);
|
|||
CREATE TABLE posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
site_id INTEGER NOT NULL,
|
||||
state INTEGER NOT NULL,
|
||||
guid TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
slug TEXT 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 UNIQUE INDEX idx_post_guid ON posts (guid);
|
||||
8
views/_common/toast.html
Normal file
8
views/_common/toast.html
Normal 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>
|
||||
|
|
@ -5,10 +5,13 @@
|
|||
<title>Title</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="/static/assets/main.css">
|
||||
<script src="/static/assets/main.js" type="module"></script>
|
||||
</head>
|
||||
<body class="min-vh-100 d-flex flex-column">
|
||||
{{ template "_common/nav" . }}
|
||||
|
||||
{{ embed }}
|
||||
|
||||
{{ template "_common/toast" . }}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,17 +1,47 @@
|
|||
{{ $showingTrash := eq .req.Filter "deleted" }}
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{{ 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 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">
|
||||
{{ 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 }}/edit"
|
||||
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>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ if lt $i (sub (len $.posts) 1) }}<hr>{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</main>
|
||||
Loading…
Reference in a new issue