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 {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
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 (
"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")
}))
}

View file

@ -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")

View file

@ -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")

View file

@ -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
View file

@ -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",

View file

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

View file

@ -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 {

View file

@ -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

View file

@ -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()
}

View file

@ -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
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"
)
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
}

View file

@ -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
}

View file

@ -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 = ?;
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 = ?;

View file

@ -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
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>
<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>

View file

@ -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>