Add blog post editing

This commit is contained in:
Ben Visness 2021-07-30 17:32:19 -05:00
parent 93318c378a
commit 9945ab061d
15 changed files with 465 additions and 350 deletions

View File

@ -414,17 +414,17 @@ func BuildBlog(projectSlug string, page int) string {
return ProjectUrl(path, nil, projectSlug) return ProjectUrl(path, nil, projectSlug)
} }
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`) var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?$`)
func BuildBlogThread(projectSlug string, threadId int, title string, page int) string { func BuildBlogThread(projectSlug string, threadId int, title string) string {
defer CatchPanic() defer CatchPanic()
builder := buildBlogThreadPath(threadId, title, page) builder := buildBlogThreadPath(threadId, title)
return ProjectUrl(builder.String(), nil, projectSlug) return ProjectUrl(builder.String(), nil, projectSlug)
} }
func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, page int, postId int) string { func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, postId int) string {
defer CatchPanic() defer CatchPanic()
builder := buildBlogThreadPath(threadId, title, page) builder := buildBlogThreadPath(threadId, title)
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId)) return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
} }
@ -463,15 +463,6 @@ func BuildBlogPostReply(projectSlug string, threadId int, postId int) string {
return ProjectUrl(builder.String(), nil, projectSlug) return ProjectUrl(builder.String(), nil, projectSlug)
} }
var RegexBlogPostQuote = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/quote$`)
func BuildBlogPostQuote(projectSlug string, threadId int, postId int) string {
defer CatchPanic()
builder := buildBlogPostPath(threadId, postId)
builder.WriteString("/quote")
return ProjectUrl(builder.String(), nil, projectSlug)
}
/* /*
* Library * Library
*/ */
@ -525,6 +516,13 @@ func BuildProjectCSS(color string) string {
return Url("/assets/project.css", []Q{{"color", color}}) return Url("/assets/project.css", []Q{{"color", color}})
} }
var RegexEditorPreviewsJS = regexp.MustCompile("^/assets/editorpreviews.js$")
func BuildEditorPreviewsJS() string {
defer CatchPanic()
return Url("/assets/editorpreviews.js", nil)
}
// NOTE(asaf): No Regex or tests for remote assets, since we don't parse it ourselves // NOTE(asaf): No Regex or tests for remote assets, since we don't parse it ourselves
func BuildS3Asset(s3key string) string { func BuildS3Asset(s3key string) string {
defer CatchPanic() defer CatchPanic()
@ -665,11 +663,7 @@ func buildForumPostPath(subforums []string, threadId int, postId int) *strings.B
return builder return builder
} }
func buildBlogThreadPath(threadId int, title string, page int) *strings.Builder { func buildBlogThreadPath(threadId int, title string) *strings.Builder {
if page < 1 {
panic(oops.New(nil, "Invalid blog thread page (%d), must be >= 1", page))
}
if threadId < 1 { if threadId < 1 {
panic(oops.New(nil, "Invalid blog thread ID (%d), must be >= 1", threadId)) panic(oops.New(nil, "Invalid blog thread ID (%d), must be >= 1", threadId))
} }
@ -682,10 +676,6 @@ func buildBlogThreadPath(threadId int, title string, page int) *strings.Builder
builder.WriteRune('-') builder.WriteRune('-')
builder.WriteString(PathSafeTitle(title)) builder.WriteString(PathSafeTitle(title))
} }
if page > 1 {
builder.WriteRune('/')
builder.WriteString(strconv.Itoa(page))
}
return &builder return &builder
} }

View File

@ -7,7 +7,7 @@
} }
.monospace { .monospace {
font-family: "Fira Mono", monospace; font-family: $monospace-fonts;
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
padding: 0.2em 0 0.05em; padding: 0.2em 0 0.05em;
border-radius: 3px; border-radius: 3px;
@ -52,7 +52,7 @@
} }
pre { pre {
font-family: "Fira Mono", monospace; font-family: $monospace-fonts;
} }
.hmn-code { .hmn-code {

View File

@ -136,8 +136,14 @@ hr {
max-width: 300px; max-width: 300px;
} }
$monospace-fonts: "Fira Mono", monospace;
.mono {
font-family: $monospace-fonts;
}
article code { article code {
font-family: "Fira Mono", monospace; font-family: $monospace-fonts;
} }
.big { font-size:120%; } .big { font-size:120%; }
@ -318,6 +324,10 @@ article code {
min-height: $height-5; min-height: $height-5;
} }
.minh-6 {
min-height: $height-6;
}
.fira { .fira {
font-family: "Fira Sans", sans-serif; font-family: "Fira Sans", sans-serif;
} }

View File

@ -1,3 +1,4 @@
// TODO: Old and wrong and bad
.toolbar { .toolbar {
@include usevar('background-color', 'editor-toolbar-background'); @include usevar('background-color', 'editor-toolbar-background');
@include usevar('border-color', 'editor-toolbar-border-color'); @include usevar('border-color', 'editor-toolbar-border-color');
@ -35,44 +36,10 @@
} }
} }
.editor { @media #{$breakpoint-not-small} {
.toolbar { #preview-container {
width:95%; max-height: calc(100vh - 20rem);
margin:10px auto; overflow: auto;
select {
font-size:10pt;
border:0px;
&:hover {
border:0px;
}
&:focus{
border:0px;
}
}
#bold {
font-weight: bold;
}
#italic {
font-style: italic;
}
#underline {
text-decoration: underline;
}
#monospace {
font-family: monospace;
}
#url {
text-decoration: underline;
font-style: italic;
}
} }
} }

View File

@ -131,7 +131,7 @@
} }
.codeblocktable { .codeblocktable {
font-family: "Fira Mono", monospace; font-family: $monospace-fonts;
font-size:14px; font-size:14px;
overflow:auto; overflow:auto;
line-height:1.5em; line-height:1.5em;
@ -406,7 +406,7 @@ li.post-entry {
border-width: 1px; border-width: 1px;
border-radius: 2px; border-radius: 2px;
font-family: "Fira Mono", monospace; font-family: $monospace-fonts;
width:49%; width:49%;
box-sizing:border-box; box-sizing:border-box;
padding: 4px; padding: 4px;

View File

@ -36,6 +36,7 @@ $height-2: 2rem !default;
$height-3: 4rem !default; $height-3: 4rem !default;
$height-4: 8rem !default; $height-4: 8rem !default;
$height-5: 16rem !default; $height-5: 16rem !default;
$height-6: 32rem !default;
$width-1: 1rem !default; $width-1: 1rem !default;
$width-2: 2rem !default; $width-2: 2rem !default;
$width-3: 4rem !default; $width-3: 4rem !default;

View File

@ -1,13 +1,14 @@
{{ template "base.html" . }} {{ template "base.html" . }}
{{ define "extrahead" }} {{ define "extrahead" }}
{{/* TODO: These are no longer useful? */}}
<link rel="stylesheet" href="{{ static "editor.css" }}" /> <link rel="stylesheet" href="{{ static "editor.css" }}" />
<script src="{{ static "util.js" }}"></script> <script src="{{ static "util.js" }}"></script>
<script src="{{ static "editor.js" }}"></script> <script src="{{ static "editor.js" }}"></script>
<script src="{{ static "go_wasm_exec.js" }}"></script> <script src="{{ static "go_wasm_exec.js" }}"></script>
<script> <script>
const previewWorker = new Worker('{{ static "js/editorpreviews.js" }}'); const previewWorker = new Worker('/assets/editorpreviews.js');
</script> </script>
<style> <style>
@ -19,15 +20,15 @@
{{ define "content" }} {{ define "content" }}
<div class="content-block ph3 ph0-ns"> <div class="content-block ph3 ph0-ns">
{{ if .Title }} {{ if not .CanEditTitle }}
<h2>{{ .Title }}</h2> <h2>{{ .Title }}</h2>
{{ end }} {{ end }}
<div class="flex flex-column flex-row-ns"> <div class="flex flex-column flex-row-ns">
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns"> <form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns">
{{ csrftoken .Session }} {{ csrftoken .Session }}
{{ if not (or .PostReplyingTo .IsEditing) }} {{ if .CanEditTitle }}
<input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..."/> <input id="title" class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .Title }}" />
{{ end }} {{ end }}
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}} {{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
{{/* {{/*
@ -51,7 +52,7 @@
<input type="button" id="youtube" value="youtube" /> <input type="button" id="youtube" value="youtube" />
</div> </div>
*/}} */}}
<textarea id="editor" class="w-100 h5 minh-5" name="body">{{ if .IsEditing }}{{ .EditInitialContents }}{{ end }}</textarea> <textarea id="editor" class="w-100 h6 minh-6 pa2 mono lh-copy" name="body">{{ .EditInitialContents }}</textarea>
<div class="flex flex-row-reverse justify-start mt2"> <div class="flex flex-row-reverse justify-start mt2">
<input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" /> <input type="submit" class="button ml2" name="submit" value="{{ .SubmitLabel }}" />
@ -99,7 +100,7 @@
{% endif %} {% endif %}
*/}} */}}
</form> </form>
<div class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns"> <div id="preview-container" class="post post-preview mv3 mathjax flex-fair-ns mv0-ns ml3-ns overflow-auto">
<div id="preview" class="body contents"></div> <div id="preview" class="body contents"></div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,33 @@
importScripts('/public/go_wasm_exec.js');
/*
NOTE(ben): The structure here is a little funny but allows for some debouncing. Any postMessages
that got queued up can run all at once, then it can process the latest one.
*/
let ready = false;
let inputData = null;
onmessage = ({ data }) => {
inputData = data;
setTimeout(doPreview, 0);
}
const go = new Go();
WebAssembly.instantiateStreaming(fetch('/public/parsing.wasm'), go.importObject)
.then(result => {
go.run(result.instance); // don't await this; we want it to be continuously running
ready = true;
setTimeout(doPreview, 0);
});
const doPreview = () => {
if (!ready || inputData === null) {
return;
}
const result = parseMarkdown(inputData);
inputData = null;
postMessage(result);
}

View File

@ -30,7 +30,7 @@ func Init() {
files, _ := templateFs.ReadDir("src") files, _ := templateFs.ReadDir("src")
for _, f := range files { for _, f := range files {
if strings.HasSuffix(f.Name(), ".html") { if hasSuffix(f.Name(), ".html") {
t := template.New(f.Name()) t := template.New(f.Name())
t = t.Funcs(sprig.FuncMap()) t = t.Funcs(sprig.FuncMap())
t = t.Funcs(HMNTemplateFuncs) t = t.Funcs(HMNTemplateFuncs)
@ -40,17 +40,7 @@ func Init() {
} }
Templates[f.Name()] = t Templates[f.Name()] = t
} else if strings.HasSuffix(f.Name(), ".css") { } else if hasSuffix(f.Name(), ".css", ".js", ".xml") {
t := template.New(f.Name())
t = t.Funcs(sprig.FuncMap())
t = t.Funcs(HMNTemplateFuncs)
t, err := t.ParseFS(templateFs, "src/"+f.Name())
if err != nil {
logging.Fatal().Str("filename", f.Name()).Err(err).Msg("failed to parse template")
}
Templates[f.Name()] = t
} else if strings.HasSuffix(f.Name(), ".xml") {
t := template.New(f.Name()) t := template.New(f.Name())
t = t.Funcs(sprig.FuncMap()) t = t.Funcs(sprig.FuncMap())
t = t.Funcs(HMNTemplateFuncs) t = t.Funcs(HMNTemplateFuncs)
@ -64,6 +54,15 @@ func Init() {
} }
} }
func hasSuffix(s string, suffixes ...string) bool {
for _, suffix := range suffixes {
if strings.HasSuffix(s, suffix) {
return true
}
}
return false
}
func names(ts []*template.Template) []string { func names(ts []*template.Template) []string {
result := make([]string, len(ts)) result := make([]string, len(ts))
for i, t := range ts { for i, t := range ts {

View File

@ -1,11 +1,14 @@
package website package website
import ( import (
"fmt"
"net/http" "net/http"
"strconv" "strconv"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
) )
@ -35,11 +38,11 @@ func BlogThread(c *RequestContext) ResponseData {
for _, p := range posts { for _, p := range posts {
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme) post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
post.AddContentVersion(p.CurrentVersion, p.Editor) post.AddContentVersion(p.CurrentVersion, p.Editor)
addBlogUrlsToPost(&post, c.CurrentProject.Slug, p.Thread.ID, p.Post.ID) addBlogUrlsToPost(&post, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
if p.ReplyPost != nil { if p.ReplyPost != nil {
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme) reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
addBlogUrlsToPost(&reply, c.CurrentProject.Slug, p.Thread.ID, p.Post.ID) addBlogUrlsToPost(&reply, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
post.ReplyPost = &reply post.ReplyPost = &reply
} }
@ -67,10 +70,83 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData {
thread := FetchThread(c.Context(), c.Conn, cd.ThreadID) thread := FetchThread(c.Context(), c.Conn, cd.ThreadID)
threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, cd.ThreadID, thread.Title, 1) threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, cd.ThreadID, thread.Title)
return c.Redirect(threadUrl, http.StatusFound) return c.Redirect(threadUrl, http.StatusFound)
} }
func BlogPostEdit(c *RequestContext) ResponseData {
cd, ok := getCommonBlogData(c)
if !ok {
return FourOhFour(c)
}
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c)
}
postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c)
if postData.Thread.FirstID == postData.Post.ID {
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name)
} else {
baseData.Title = fmt.Sprintf("Editing Post | %s", c.CurrentProject.Name)
}
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
editData := getEditorDataForEdit(baseData, postData)
editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Edited Post"
if postData.Thread.FirstID != postData.Post.ID {
editData.SubmitLabel = "Submit Edited Comment"
}
var res ResponseData
res.MustWriteTemplate("editor.html", editData, c.Perf)
return res
}
func BlogPostEditSubmit(c *RequestContext) ResponseData {
cd, ok := getCommonBlogData(c)
if !ok {
return FourOhFour(c)
}
if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
postData := FetchPostAndStuff(c.Context(), tx, cd.ThreadID, cd.PostID)
c.Req.ParseForm()
title := c.Req.Form.Get("title")
unparsed := c.Req.Form.Get("body")
editReason := c.Req.Form.Get("editreason")
if title != "" && postData.Thread.FirstID != postData.Post.ID {
return RejectRequest(c, "You can only edit the title by editing the first post.")
}
if unparsed == "" {
return RejectRequest(c, "You must provide a post body.")
}
CreatePostVersion(c.Context(), tx, postData.Post.ID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit blog post"))
}
postUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, postData.Thread.Title, cd.PostID)
return c.Redirect(postUrl, http.StatusSeeOther)
}
type commonBlogData struct { type commonBlogData struct {
c *RequestContext c *RequestContext
@ -145,9 +221,9 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
return res, true return res, true
} }
func addBlogUrlsToPost(p *templates.Post, projectSlug string, threadId int, postId int) { func addBlogUrlsToPost(p *templates.Post, projectSlug string, thread *models.Thread, postId int) {
p.Url = hmnurl.BuildBlogPost(projectSlug, threadId, postId) p.Url = hmnurl.BuildBlogThreadWithPostHash(projectSlug, thread.ID, thread.Title, postId)
p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, threadId, postId) p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, thread.ID, postId)
p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, threadId, postId) p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, thread.ID, postId)
p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, threadId, postId) p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, thread.ID, postId)
} }

View File

@ -1,11 +1,8 @@
package website package website
import ( import (
"context"
"errors"
"fmt" "fmt"
"math" "math"
"net"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
@ -15,10 +12,8 @@ import (
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/parsing"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils" "git.handmade.network/hmn/hmn/src/utils"
"github.com/jackc/pgx/v4"
) )
type forumData struct { type forumData struct {
@ -38,18 +33,46 @@ type forumSubforumData struct {
TotalThreads int TotalThreads int
} }
type editActionType string
type editorData struct { type editorData struct {
templates.BaseData templates.BaseData
SubmitUrl string SubmitUrl string
Title string
SubmitLabel string SubmitLabel string
IsEditing bool // false if new post, true if updating existing one // The following are filled out automatically by the
// getEditorDataFor* functions.
Title string
CanEditTitle bool
IsEditing bool
EditInitialContents string EditInitialContents string
PostReplyingTo *templates.Post PostReplyingTo *templates.Post
} }
func getEditorDataForNew(baseData templates.BaseData, replyPost *templates.Post) editorData {
result := editorData{
BaseData: baseData,
CanEditTitle: replyPost == nil,
PostReplyingTo: replyPost,
}
if replyPost != nil {
result.Title = "Replying to post"
}
return result
}
func getEditorDataForEdit(baseData templates.BaseData, p postAndRelatedModels) editorData {
return editorData{
BaseData: baseData,
Title: p.Thread.Title,
CanEditTitle: p.Thread.FirstID == p.Post.ID,
IsEditing: true,
EditInitialContents: p.CurrentVersion.TextRaw,
}
}
func Forum(c *RequestContext) ResponseData { func Forum(c *RequestContext) ResponseData {
const threadsPerPage = 25 const threadsPerPage = 25
@ -590,12 +613,12 @@ func ForumNewThread(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
editData := getEditorDataForNew(baseData, nil)
editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
editData.SubmitLabel = "Post New Thread"
var res ResponseData var res ResponseData
res.MustWriteTemplate("editor.html", editorData{ res.MustWriteTemplate("editor.html", editData, c.Perf)
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true),
SubmitLabel: "Post New Thread",
}, c.Perf)
return res return res
} }
@ -642,23 +665,8 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
panic(oops.New(err, "failed to create thread")) panic(oops.New(err, "failed to create thread"))
} }
postId, _ := createNewForumPostAndVersion(c.Context(), tx, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, nil) // Create everything else
CreateNewPost(c.Context(), tx, c.CurrentProject.ID, threadId, models.ThreadTypeForumPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
// Update thread with post id
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_thread
SET
first_id = $1,
last_id = $1
WHERE id = $2
`,
postId,
threadId,
)
if err != nil {
panic(oops.New(err, "failed to set thread post ids"))
}
err = tx.Commit(c.Context()) err = tx.Commit(c.Context())
if err != nil { if err != nil {
@ -685,15 +693,12 @@ func ForumPostReply(c *RequestContext) ResponseData {
templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor)
var res ResponseData editData := getEditorDataForNew(baseData, &templatePost)
res.MustWriteTemplate("editor.html", editorData{ editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
BaseData: baseData, editData.SubmitLabel = "Submit Reply"
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
SubmitLabel: "Submit Reply",
Title: "Replying to post", var res ResponseData
PostReplyingTo: &templatePost, res.MustWriteTemplate("editor.html", editData, c.Perf)
}, c.Perf)
return res return res
} }
@ -711,10 +716,9 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
c.Req.ParseForm() c.Req.ParseForm()
// TODO(ben): Validation // TODO(ben): Validation
unparsed := c.Req.Form.Get("body") unparsed := c.Req.Form.Get("body")
newPostId, _ := createNewForumPostAndVersion(c.Context(), tx, cd.ThreadID, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host, &cd.PostID) newPostId, _ := CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeForumPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host)
err = tx.Commit(c.Context()) err = tx.Commit(c.Context())
if err != nil { if err != nil {
@ -731,30 +735,27 @@ func ForumPostEdit(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c) return FourOhFour(c)
} }
postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID) postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c) baseData := getBaseData(c)
if postData.Thread.FirstID == postData.Post.ID {
baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name) baseData.Title = fmt.Sprintf("Editing \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
} else {
baseData.Title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name)
}
baseData.MathjaxEnabled = true baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs // TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme) editData := getEditorDataForEdit(baseData, postData)
templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor) editData.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Edited Post"
var res ResponseData var res ResponseData
res.MustWriteTemplate("editor.html", editorData{ res.MustWriteTemplate("editor.html", editData, c.Perf)
BaseData: baseData,
SubmitUrl: hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
Title: postData.Thread.Title,
SubmitLabel: "Submit Edited Post",
IsEditing: true,
EditInitialContents: postData.CurrentVersion.TextRaw,
}, c.Perf)
return res return res
} }
@ -764,7 +765,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c) return FourOhFour(c)
} }
@ -779,7 +780,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
unparsed := c.Req.Form.Get("body") unparsed := c.Req.Form.Get("body")
editReason := c.Req.Form.Get("editreason") editReason := c.Req.Form.Get("editreason")
createForumPostVersion(c.Context(), tx, cd.PostID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID) CreatePostVersion(c.Context(), tx, cd.PostID, unparsed, c.Req.Host, editReason, &c.CurrentUser.ID)
err = tx.Commit(c.Context()) err = tx.Commit(c.Context())
if err != nil { if err != nil {
@ -796,7 +797,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c) return FourOhFour(c)
} }
@ -831,7 +832,7 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
return FourOhFour(c) return FourOhFour(c)
} }
if !cd.UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser) { if !UserCanEditPost(c.Context(), c.Conn, *c.CurrentUser, cd.PostID) {
return FourOhFour(c) return FourOhFour(c)
} }
@ -841,208 +842,20 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
} }
defer tx.Rollback(c.Context()) defer tx.Rollback(c.Context())
isFirstPost, err := db.QueryBool(c.Context(), tx, threadDeleted := DeletePost(c.Context(), tx, cd.ThreadID, cd.PostID)
`
SELECT thread.first_id = $1
FROM
handmade_thread AS thread
WHERE
thread.id = $2
`,
cd.PostID,
cd.ThreadID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to check if post was the first post in the thread"))
}
if isFirstPost {
// Just delete the whole thread and all its posts.
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_thread
SET deleted = TRUE
WHERE id = $1
`,
cd.ThreadID,
)
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_post
SET deleted = TRUE
WHERE thread_id = $1
`,
cd.ThreadID,
)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete thread and posts when deleting the first post"))
}
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
return c.Redirect(forumUrl, http.StatusSeeOther)
}
_, err = tx.Exec(c.Context(),
`
UPDATE handmade_post
SET deleted = TRUE
WHERE
id = $1
`,
cd.PostID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to mark forum post as deleted"))
}
err = fixThreadPostIds(c.Context(), tx, cd.ThreadID)
if err != nil {
if errors.Is(err, errThreadEmpty) {
panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier")
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fix up thread post ids"))
}
}
err = tx.Commit(c.Context()) err = tx.Commit(c.Context())
if err != nil { if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post")) return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
} }
if threadDeleted {
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
return c.Redirect(forumUrl, http.StatusSeeOther)
} else {
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted? threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
return c.Redirect(threadUrl, http.StatusSeeOther) return c.Redirect(threadUrl, http.StatusSeeOther)
} }
func createNewForumPostAndVersion(ctx context.Context, tx pgx.Tx, threadId, userId, projectId int, unparsedContent string, ipString string, replyId *int) (postId, versionId int) {
// Create post
err := tx.QueryRow(ctx,
`
INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`,
time.Now(),
threadId,
models.ThreadTypeForumPost,
-1,
userId,
projectId,
replyId,
"", // empty preview, will be updated later
).Scan(&postId)
if err != nil {
panic(oops.New(err, "failed to create post"))
}
versionId = createForumPostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
return
}
func createForumPostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
parsed := parsing.ParseMarkdown(unparsedContent, parsing.RealMarkdown)
ip := net.ParseIP(ipString)
const previewMaxLength = 100
parsedPlaintext := parsing.ParseMarkdown(unparsedContent, parsing.PlaintextMarkdown)
preview := parsedPlaintext
if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…"
}
// Create post version
err := tx.QueryRow(ctx,
`
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
postId,
unparsedContent,
parsed,
ip,
time.Now(),
editReason,
editorId,
).Scan(&versionId)
if err != nil {
panic(oops.New(err, "failed to create post version"))
}
// Update post with version id and preview
_, err = tx.Exec(ctx,
`
UPDATE handmade_post
SET current_id = $1, preview = $2
WHERE id = $3
`,
versionId,
preview,
postId,
)
if err != nil {
panic(oops.New(err, "failed to set current post version and preview"))
}
return
}
var errThreadEmpty = errors.New("thread contained no non-deleted posts")
/*
Ensures that the first_id and last_id on the thread are still good.
Returns errThreadEmpty if the thread contains no visible posts any more.
You should probably mark the thread as deleted in this case.
*/
func fixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
postsIter, err := db.Query(ctx, tx, models.Post{},
`
SELECT $columns
FROM handmade_post
WHERE
thread_id = $1
AND NOT deleted
`,
threadId,
)
if err != nil {
return oops.New(err, "failed to fetch posts when fixing up thread")
}
var firstPost, lastPost *models.Post
for _, ipost := range postsIter.ToSlice() {
post := ipost.(*models.Post)
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
firstPost = post
}
if lastPost == nil || post.PostDate.After(lastPost.PostDate) {
lastPost = post
}
}
if firstPost == nil || lastPost == nil {
return errThreadEmpty
}
_, err = tx.Exec(ctx,
`
UPDATE handmade_thread
SET first_id = $1, last_id = $2
WHERE id = $3
`,
firstPost.ID,
lastPost.ID,
threadId,
)
if err != nil {
return oops.New(err, "failed to update thread first/last ids")
}
return nil
} }
type commonForumData struct { type commonForumData struct {

View File

@ -182,7 +182,7 @@ func Index(c *RequestContext) ResponseData {
landingPageProject.FeaturedPost = &LandingPageFeaturedPost{ landingPageProject.FeaturedPost = &LandingPageFeaturedPost{
Title: projectPost.Thread.Title, Title: projectPost.Thread.Title,
Url: hmnurl.BuildBlogPost(proj.Slug, projectPost.Thread.ID, projectPost.Post.ID), Url: hmnurl.BuildBlogThread(proj.Slug, projectPost.Thread.ID, projectPost.Thread.Title),
User: templates.UserToTemplate(&projectPost.User, c.Theme), User: templates.UserToTemplate(&projectPost.User, c.Theme),
Date: projectPost.Post.PostDate, Date: projectPost.Post.PostDate,
Unread: !hasRead, Unread: !hasRead,
@ -323,13 +323,13 @@ func Index(c *RequestContext) ResponseData {
FeedUrl: hmnurl.BuildFeed(), FeedUrl: hmnurl.BuildFeed(),
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug), PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
StreamsUrl: hmnurl.BuildStreams(), StreamsUrl: hmnurl.BuildStreams(),
IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC", 1), IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC"),
DiscordUrl: "https://discord.gg/hxWxDee", DiscordUrl: "https://discord.gg/hxWxDee",
ShowUrl: "https://handmadedev.show/", ShowUrl: "https://handmadedev.show/",
ShowcaseUrl: hmnurl.BuildShowcase(), ShowcaseUrl: hmnurl.BuildShowcase(),
NewsPost: LandingPageFeaturedPost{ NewsPost: LandingPageFeaturedPost{
Title: newsPostResult.Thread.Title, Title: newsPostResult.Thread.Title,
Url: hmnurl.BuildBlogPost(models.HMNProjectSlug, newsPostResult.Thread.ID, newsPostResult.Post.ID), Url: hmnurl.BuildBlogThread(models.HMNProjectSlug, newsPostResult.Thread.ID, newsPostResult.Thread.Title),
User: templates.UserToTemplate(&newsPostResult.User, c.Theme), User: templates.UserToTemplate(&newsPostResult.User, c.Theme),
Date: newsPostResult.Post.PostDate, Date: newsPostResult.Post.PostDate,
Unread: true, // TODO Unread: true, // TODO

View File

@ -10,7 +10,7 @@ import (
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string { func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
switch post.ThreadType { switch post.ThreadType {
case models.ThreadTypeProjectArticle: case models.ThreadTypeProjectArticle:
return hmnurl.BuildBlogPost(projectSlug, post.ThreadID, post.ID) return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID)
case models.ThreadTypeForumPost: case models.ThreadTypeForumPost:
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID) return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
} }

View File

@ -168,6 +168,8 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread) mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread) mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
mainRoutes.GET(hmnurl.RegexBlogPostEdit, BlogPostEdit)
mainRoutes.POST(hmnurl.RegexBlogPostEdit, BlogPostEditSubmit)
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex) mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit) mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)
@ -180,6 +182,12 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS) mainRoutes.GET(hmnurl.RegexPodcastRSS, PodcastRSS)
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
mainRoutes.GET(hmnurl.RegexEditorPreviewsJS, func(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("editorpreviews.js", nil, c.Perf)
res.Header().Add("Content-Type", "application/javascript")
return res
})
// Other // Other
mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour) mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)

View File

@ -4,10 +4,14 @@ import (
"context" "context"
"errors" "errors"
"math" "math"
"net"
"time"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/parsing"
"github.com/jackc/pgx/v4"
) )
type postAndRelatedModels struct { type postAndRelatedModels struct {
@ -187,7 +191,7 @@ func FetchThreadPostsAndStuff(
return thread, posts return thread, posts
} }
func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User) bool { func UserCanEditPost(ctx context.Context, connOrTx db.ConnOrTx, user models.User, postId int) bool {
if user.IsStaff { if user.IsStaff {
return true return true
} }
@ -204,7 +208,7 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
post.id = $1 post.id = $1
AND NOT post.deleted AND NOT post.deleted
`, `,
cd.PostID, postId,
) )
if err != nil { if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) { if errors.Is(err, db.ErrNoMatchingRows) {
@ -217,3 +221,216 @@ func (cd *commonForumData) UserCanEditPost(ctx context.Context, connOrTx db.Conn
return result.AuthorID != nil && *result.AuthorID == user.ID return result.AuthorID != nil && *result.AuthorID == user.ID
} }
func CreateNewPost(
ctx context.Context,
tx pgx.Tx,
projectId int,
threadId int, threadType models.ThreadType,
userId int,
replyId *int,
unparsedContent string,
ipString string,
) (postId, versionId int) {
// Create post
err := tx.QueryRow(ctx,
`
INSERT INTO handmade_post (postdate, thread_id, thread_type, current_id, author_id, project_id, reply_id, preview)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id
`,
time.Now(),
threadId,
models.ThreadTypeForumPost,
-1,
userId,
projectId,
replyId,
"", // empty preview, will be updated later
).Scan(&postId)
if err != nil {
panic(oops.New(err, "failed to create post"))
}
// Create and associate version
versionId = CreatePostVersion(ctx, tx, postId, unparsedContent, ipString, "", nil)
// Fix up thread
err = FixThreadPostIds(ctx, tx, threadId)
if err != nil {
panic(oops.New(err, "failed to fix up thread post IDs"))
}
return
}
func DeletePost(
ctx context.Context,
tx pgx.Tx,
threadId, postId int,
) (threadDeleted bool) {
isFirstPost, err := db.QueryBool(ctx, tx,
`
SELECT thread.first_id = $1
FROM
handmade_thread AS thread
WHERE
thread.id = $2
`,
postId,
threadId,
)
if err != nil {
panic(oops.New(err, "failed to check if post was the first post in the thread"))
}
if isFirstPost {
// Just delete the whole thread and all its posts.
_, err = tx.Exec(ctx,
`
UPDATE handmade_thread
SET deleted = TRUE
WHERE id = $1
`,
threadId,
)
_, err = tx.Exec(ctx,
`
UPDATE handmade_post
SET deleted = TRUE
WHERE thread_id = $1
`,
threadId,
)
return true
}
_, err = tx.Exec(ctx,
`
UPDATE handmade_post
SET deleted = TRUE
WHERE
id = $1
`,
postId,
)
if err != nil {
panic(oops.New(err, "failed to mark forum post as deleted"))
}
err = FixThreadPostIds(ctx, tx, threadId)
if err != nil {
if errors.Is(err, errThreadEmpty) {
panic("it shouldn't be possible to delete the last remaining post in a thread, without it also being the first post in the thread and thus resulting in the whole thread getting deleted earlier")
} else {
panic(oops.New(err, "failed to fix up thread post ids"))
}
}
return false
}
func CreatePostVersion(ctx context.Context, tx pgx.Tx, postId int, unparsedContent string, ipString string, editReason string, editorId *int) (versionId int) {
parsed := parsing.ParseMarkdown(unparsedContent, parsing.RealMarkdown)
ip := net.ParseIP(ipString)
const previewMaxLength = 100
parsedPlaintext := parsing.ParseMarkdown(unparsedContent, parsing.PlaintextMarkdown)
preview := parsedPlaintext
if len(preview) > previewMaxLength-1 {
preview = preview[:previewMaxLength-1] + "…"
}
// Create post version
err := tx.QueryRow(ctx,
`
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date, edit_reason, editor_id)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id
`,
postId,
unparsedContent,
parsed,
ip,
time.Now(),
editReason,
editorId,
).Scan(&versionId)
if err != nil {
panic(oops.New(err, "failed to create post version"))
}
// Update post with version id and preview
_, err = tx.Exec(ctx,
`
UPDATE handmade_post
SET current_id = $1, preview = $2
WHERE id = $3
`,
versionId,
preview,
postId,
)
if err != nil {
panic(oops.New(err, "failed to set current post version and preview"))
}
return
}
var errThreadEmpty = errors.New("thread contained no non-deleted posts")
/*
Ensures that the first_id and last_id on the thread are still good.
Returns errThreadEmpty if the thread contains no visible posts any more.
You should probably mark the thread as deleted in this case.
*/
func FixThreadPostIds(ctx context.Context, tx pgx.Tx, threadId int) error {
postsIter, err := db.Query(ctx, tx, models.Post{},
`
SELECT $columns
FROM handmade_post
WHERE
thread_id = $1
AND NOT deleted
`,
threadId,
)
if err != nil {
return oops.New(err, "failed to fetch posts when fixing up thread")
}
var firstPost, lastPost *models.Post
for _, ipost := range postsIter.ToSlice() {
post := ipost.(*models.Post)
if firstPost == nil || post.PostDate.Before(firstPost.PostDate) {
firstPost = post
}
if lastPost == nil || post.PostDate.After(lastPost.PostDate) {
lastPost = post
}
}
if firstPost == nil || lastPost == nil {
return errThreadEmpty
}
_, err = tx.Exec(ctx,
`
UPDATE handmade_thread
SET first_id = $1, last_id = $2
WHERE id = $3
`,
firstPost.ID,
lastPost.ID,
threadId,
)
if err != nil {
return oops.New(err, "failed to update thread first/last ids")
}
return nil
}