From 3ea5823ca0233585e7e7ed9498695576d7c08c93 Mon Sep 17 00:00:00 2001 From: Leon Mika Date: Mon, 23 Feb 2026 21:18:34 +1100 Subject: [PATCH] Have got soft and hard deleting --- .../posts/2026/02/23-another-post-to.md | 8 ++ _test-site/posts/2026/02/23-i-should-soft.md | 8 ++ _test-site/posts/2026/02/23-this-is-to.md | 8 ++ assets/js/controllers/postlist.js | 77 +++++++++++++ assets/js/controllers/toast.js | 24 ++++ assets/js/main.js | 8 ++ assets/js/services/toast.js | 6 + esbuild.mjs | 19 +++- handlers/accepts.go | 35 ++++++ handlers/posts.go | 105 ++++++++++++++++-- main.go | 5 +- models/errors.go | 1 + models/posts.go | 41 +++++-- package-lock.json | 7 ++ package.json | 1 + providers/db/gen/sqlgen/models.go | 3 + providers/db/gen/sqlgen/posts.sql.go | 87 +++++++++++++-- providers/db/posts.go | 34 +++++- providers/db/provider.go | 16 +++ services/posts/delete.go | 46 ++++++++ services/posts/list.go | 4 +- services/publisher/service.go | 2 +- sql/queries/posts.sql | 32 +++++- sql/schema/01_init.up.sql | 9 +- views/_common/toast.html | 8 ++ views/layouts/main.html | 3 + views/posts/index.html | 46 ++++++-- 27 files changed, 588 insertions(+), 55 deletions(-) create mode 100644 _test-site/posts/2026/02/23-another-post-to.md create mode 100644 _test-site/posts/2026/02/23-i-should-soft.md create mode 100644 _test-site/posts/2026/02/23-this-is-to.md create mode 100644 assets/js/controllers/postlist.js create mode 100644 assets/js/controllers/toast.js create mode 100644 assets/js/main.js create mode 100644 assets/js/services/toast.js create mode 100644 handlers/accepts.go create mode 100644 services/posts/delete.go create mode 100644 views/_common/toast.html diff --git a/_test-site/posts/2026/02/23-another-post-to.md b/_test-site/posts/2026/02/23-another-post-to.md new file mode 100644 index 0000000..aaa3b4a --- /dev/null +++ b/_test-site/posts/2026/02/23-another-post-to.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/23-i-should-soft.md b/_test-site/posts/2026/02/23-i-should-soft.md new file mode 100644 index 0000000..f53706a --- /dev/null +++ b/_test-site/posts/2026/02/23-i-should-soft.md @@ -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. \ No newline at end of file diff --git a/_test-site/posts/2026/02/23-this-is-to.md b/_test-site/posts/2026/02/23-this-is-to.md new file mode 100644 index 0000000..f4aedf5 --- /dev/null +++ b/_test-site/posts/2026/02/23-this-is-to.md @@ -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. \ No newline at end of file diff --git a/assets/js/controllers/postlist.js b/assets/js/controllers/postlist.js new file mode 100644 index 0000000..40e3d44 --- /dev/null +++ b/assets/js/controllers/postlist.js @@ -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.", + }); + } + } +} diff --git a/assets/js/controllers/toast.js b/assets/js/controllers/toast.js new file mode 100644 index 0000000..1655dbf --- /dev/null +++ b/assets/js/controllers/toast.js @@ -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(); + } + } +} diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..b831564 --- /dev/null +++ b/assets/js/main.js @@ -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); diff --git a/assets/js/services/toast.js b/assets/js/services/toast.js new file mode 100644 index 0000000..7f810ec --- /dev/null +++ b/assets/js/services/toast.js @@ -0,0 +1,6 @@ +export function showToast(details) { + let event = new CustomEvent('weiroToast', { + detail: details + }); + window.dispatchEvent(event); +} \ No newline at end of file diff --git a/esbuild.mjs b/esbuild.mjs index 188a9b1..2aabc8e 100644 --- a/esbuild.mjs +++ b/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', -}); \ No newline at end of file +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', + }) +]); \ No newline at end of file diff --git a/handlers/accepts.go b/handlers/accepts.go new file mode 100644 index 0000000..f4dd3bf --- /dev/null +++ b/handlers/accepts.go @@ -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, + } +} diff --git a/handlers/posts.go b/handlers/posts.go index 82d9aa0..ff9f609 100644 --- a/handlers/posts.go +++ b/handlers/posts.go @@ -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") + })) } diff --git a/main.go b/main.go index fbd9c31..1870e63 100644 --- a/main.go +++ b/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") diff --git a/models/errors.go b/models/errors.go index 4a23c08..997a952 100644 --- a/models/errors.go +++ b/models/errors.go @@ -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") diff --git a/models/posts.go b/models/posts.go index 16184d6..e129a57 100644 --- a/models/posts.go +++ b/models/posts.go @@ -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 { diff --git a/package-lock.json b/package-lock.json index ffe8353..c4f391c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 34b5b2f..64e6fca 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "esbuild": "0.27.3" }, "dependencies": { + "@hotwired/stimulus": "^3.2.2", "bootstrap": "^5.3.8", "esbuild-sass-plugin": "^3.6.0" } diff --git a/providers/db/gen/sqlgen/models.go b/providers/db/gen/sqlgen/models.go index 5d8f3b5..6f49712 100644 --- a/providers/db/gen/sqlgen/models.go +++ b/providers/db/gen/sqlgen/models.go @@ -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 { diff --git a/providers/db/gen/sqlgen/posts.sql.go b/providers/db/gen/sqlgen/posts.sql.go index adeefde..e4b00f0 100644 --- a/providers/db/gen/sqlgen/posts.sql.go +++ b/providers/db/gen/sqlgen/posts.sql.go @@ -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 diff --git a/providers/db/posts.go b/providers/db/posts.go index 57dfbe8..04a7a3e 100644 --- a/providers/db/posts.go +++ b/providers/db/posts.go @@ -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() +} diff --git a/providers/db/provider.go b/providers/db/provider.go index b061b32..eda0513 100644 --- a/providers/db/provider.go +++ b/providers/db/provider.go @@ -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) +} diff --git a/services/posts/delete.go b/services/posts/delete.go new file mode 100644 index 0000000..6d53690 --- /dev/null +++ b/services/posts/delete.go @@ -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) +} diff --git a/services/posts/list.go b/services/posts/list.go index ba0d7a7..69ef8db 100644 --- a/services/posts/list.go +++ b/services/posts/list.go @@ -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 } diff --git a/services/publisher/service.go b/services/publisher/service.go index fe43854..2cc47c8 100644 --- a/services/publisher/service.go +++ b/services/publisher/service.go @@ -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 } diff --git a/sql/queries/posts.sql b/sql/queries/posts.sql index 2a95e08..3f740bf 100644 --- a/sql/queries/posts.sql +++ b/sql/queries/posts.sql @@ -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 = ? -WHERE id = ?; \ No newline at end of file + 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 = ?; \ No newline at end of file diff --git a/sql/schema/01_init.up.sql b/sql/schema/01_init.up.sql index 0c0e1e2..e4735a2 100644 --- a/sql/schema/01_init.up.sql +++ b/sql/schema/01_init.up.sql @@ -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); \ No newline at end of file diff --git a/views/_common/toast.html b/views/_common/toast.html new file mode 100644 index 0000000..5a70d6d --- /dev/null +++ b/views/_common/toast.html @@ -0,0 +1,8 @@ + \ No newline at end of file diff --git a/views/layouts/main.html b/views/layouts/main.html index 940528f..0480166 100644 --- a/views/layouts/main.html +++ b/views/layouts/main.html @@ -5,10 +5,13 @@ Title + {{ template "_common/nav" . }} {{ embed }} + + {{ template "_common/toast" . }} \ No newline at end of file diff --git a/views/posts/index.html b/views/posts/index.html index 7e9b85d..b395be0 100644 --- a/views/posts/index.html +++ b/views/posts/index.html @@ -1,17 +1,47 @@ +{{ $showingTrash := eq .req.Filter "deleted" }}
-
+
New Post + +
+
+ {{ if $showingTrash }} + 🗑️ + {{ else }} + 🗑️ + {{ end }} +
+
{{ range $i, $p := .posts }} - {{ if gt $i 0 }}
{{ end }} -
- {{ if $p.Title }}

{{ $p.Title }}

{{ end }} - {{ $p.Body | markdown }} -
- Edit +
+
+ {{ if $p.Title }}

{{ $p.Title }}

{{ end }} + {{ $p.Body | markdown }} + + {{ if $showingTrash }} +
+ Restore + - + Delete +
+ {{ else }} +
+ Edit + - + Delete +
+ {{ end }}
+ {{ if lt $i (sub (len $.posts) 1) }}
{{ end }}
{{ end }}
\ No newline at end of file