Add blog post editing
This commit is contained in:
parent
93318c378a
commit
9945ab061d
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in New Issue