Add all the rest of the blog post operations

Still need to add UI for the blog index, and fix some aesthetic issues:

- Wide posts can break the editor UI
- Blog comments don't show the fancy reply UI
- The post hash stuff on blog threads doesn't jump you to the correct
post

Probably other stuff, I dunno.
This commit is contained in:
Ben Visness 2021-07-30 18:08:42 -05:00
parent 9945ab061d
commit 1f4dd335c5
13 changed files with 266 additions and 34 deletions

View File

@ -428,6 +428,13 @@ func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string,
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
}
var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`)
func BuildBlogNewThread(projectSlug string) string {
defer CatchPanic()
return ProjectUrl("/blog/new", nil, projectSlug)
}
var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)$`)
func BuildBlogPost(projectSlug string, threadId int, postId int) string {

View File

@ -3,13 +3,13 @@ package models
type ThreadType int
const (
ThreadTypeProjectArticle ThreadType = iota + 1
ThreadTypeProjectBlogPost ThreadType = iota + 1
ThreadTypeForumPost
_ // formerly occupied by static pages, RIP
_ // formerly occupied by who the hell knows what, RIP
_ // formerly occupied by the wiki, RIP
_ // formerly occupied by library discussions, RIP
ThreadTypePersonalArticle
ThreadTypePersonalBlogPost
)
type Thread struct {

View File

@ -0,0 +1,12 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="mw7 margin-center">
<h3 class="mb3">Are you sure you want to delete this post?</h3>
{{ template "forum_post_standalone.html" .Post }}
<form action="{{ .SubmitUrl }}" method="POST" class="pv3 flex justify-end">
{{ csrftoken .Session }}
<input type="submit" value="Delete Post">
</form>
</div>
{{ end }}

View File

@ -54,10 +54,12 @@ func BlogThread(c *RequestContext) ResponseData {
var res ResponseData
res.MustWriteTemplate("blog_post.html", blogPostData{
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
MainPost: templatePosts[0],
Comments: templatePosts[1:],
BaseData: baseData,
Thread: templates.ThreadToTemplate(&thread),
MainPost: templatePosts[0],
Comments: templatePosts[1:],
ReplyLink: hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, posts[0].Post.ID),
LoginLink: hmnurl.BuildLoginPage(c.FullUrl()),
}, c.Perf)
return res
}
@ -74,6 +76,71 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData {
return c.Redirect(threadUrl, http.StatusFound)
}
func BlogNewThread(c *RequestContext) ResponseData {
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Create New Post | %s", c.CurrentProject.Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
editData := getEditorDataForNew(baseData, nil)
editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug)
editData.SubmitLabel = "Create Post"
var res ResponseData
res.MustWriteTemplate("editor.html", editData, c.Perf)
return res
}
func BlogNewThreadSubmit(c *RequestContext) ResponseData {
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
err = c.Req.ParseForm()
if err != nil {
return ErrorResponse(http.StatusBadRequest, oops.New(err, "the form data was invalid"))
}
title := c.Req.Form.Get("title")
unparsed := c.Req.Form.Get("body")
if title == "" {
return RejectRequest(c, "You must provide a title for your post.")
}
if unparsed == "" {
return RejectRequest(c, "You must provide a body for your post.")
}
// Create thread
var threadId int
err = tx.QueryRow(c.Context(),
`
INSERT INTO handmade_thread (title, type, project_id, first_id, last_id)
VALUES ($1, $2, $3, $4, $5)
RETURNING id
`,
title,
models.ThreadTypeProjectBlogPost,
c.CurrentProject.ID,
-1,
-1,
).Scan(&threadId)
if err != nil {
panic(oops.New(err, "failed to create thread"))
}
// Create everything else
CreateNewPost(c.Context(), tx, c.CurrentProject.ID, threadId, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, nil, unparsed, c.Req.Host)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post"))
}
newThreadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, threadId, title)
return c.Redirect(newThreadUrl, http.StatusSeeOther)
}
func BlogPostEdit(c *RequestContext) ResponseData {
cd, ok := getCommonBlogData(c)
if !ok {
@ -147,6 +214,135 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
return c.Redirect(postUrl, http.StatusSeeOther)
}
func BlogPostReply(c *RequestContext) ResponseData {
cd, ok := getCommonBlogData(c)
if !ok {
return FourOhFour(c)
}
postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Replying to comment in \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor)
editData := getEditorDataForNew(baseData, &replyPost)
editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Reply"
var res ResponseData
res.MustWriteTemplate("editor.html", editData, c.Perf)
return res
}
func BlogPostReplySubmit(c *RequestContext) ResponseData {
cd, ok := getCommonBlogData(c)
if !ok {
return FourOhFour(c)
}
tx, err := c.Conn.Begin(c.Context())
if err != nil {
panic(err)
}
defer tx.Rollback(c.Context())
err = c.Req.ParseForm()
if err != nil {
return ErrorResponse(http.StatusBadRequest, oops.New(nil, "the form data was invalid"))
}
unparsed := c.Req.Form.Get("body")
if unparsed == "" {
return RejectRequest(c, "Your reply cannot be empty.")
}
newPostId, _ := CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeProjectBlogPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post"))
}
newPostUrl := hmnurl.BuildBlogPost(c.CurrentProject.Slug, cd.ThreadID, newPostId)
return c.Redirect(newPostUrl, http.StatusSeeOther)
}
func BlogPostDelete(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("Deleting \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name)
} else {
baseData.Title = fmt.Sprintf("Deleting comment in \"%s\" | %s", postData.Thread.Title, c.CurrentProject.Name)
}
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor)
type blogPostDeleteData struct {
templates.BaseData
Post templates.Post
SubmitUrl string
}
var res ResponseData
res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{
BaseData: baseData,
SubmitUrl: hmnurl.BuildBlogPostDelete(c.CurrentProject.Slug, cd.ThreadID, cd.PostID),
Post: templatePost,
}, c.Perf)
return res
}
func BlogPostDeleteSubmit(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())
threadDeleted := DeletePost(c.Context(), tx, cd.ThreadID, cd.PostID)
err = tx.Commit(c.Context())
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to delete post"))
}
if threadDeleted {
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
return c.Redirect(projectUrl, http.StatusSeeOther)
} else {
thread := FetchThread(c.Context(), c.Conn, cd.ThreadID)
threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.ID, thread.Title)
return c.Redirect(threadUrl, http.StatusSeeOther)
}
}
type commonBlogData struct {
c *RequestContext

View File

@ -41,7 +41,7 @@ func Feed(c *RequestContext) ResponseData {
AND deleted = FALSE
AND post.thread_id IS NOT NULL
`,
[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost},
)
c.Perf.EndBlock()
if err != nil {
@ -339,7 +339,7 @@ func fetchAllPosts(c *RequestContext, lineageBuilder *models.SubforumLineageBuil
LIMIT $3 OFFSET $4
`,
currentUserID,
[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectArticle},
[]models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost},
limit,
offset,
)

View File

@ -634,16 +634,22 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
return FourOhFour(c)
}
c.Req.ParseForm()
err = c.Req.ParseForm()
if err != nil {
return ErrorResponse(http.StatusBadRequest, oops.New(err, "the form data was invalid"))
}
title := c.Req.Form.Get("title")
unparsed := c.Req.Form.Get("body")
sticky := false
if c.CurrentUser.IsStaff && c.Req.Form.Get("sticky") != "" {
sticky = true
}
// TODO(ben): Validation (and error handling if ParseForm fails? might not need it since you'll get empty values)
if title == "" {
return RejectRequest(c, "You must provide a title for your post.")
}
if unparsed == "" {
return RejectRequest(c, "You must provide a body for your post.")
}
// Create thread
var threadId int
@ -686,14 +692,14 @@ func ForumPostReply(c *RequestContext) ResponseData {
postData := FetchPostAndStuff(c.Context(), c.Conn, cd.ThreadID, cd.PostID)
baseData := getBaseData(c)
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", postData.Thread.Title, cd.SubforumTree[cd.SubforumID].Name)
baseData.Title = fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name)
baseData.MathjaxEnabled = true
// TODO(ben): Set breadcrumbs
templatePost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
templatePost.AddContentVersion(postData.CurrentVersion, postData.Editor)
replyPost := templates.PostToTemplate(&postData.Post, postData.Author, c.Theme)
replyPost.AddContentVersion(postData.CurrentVersion, postData.Editor)
editData := getEditorDataForNew(baseData, &templatePost)
editData := getEditorDataForNew(baseData, &replyPost)
editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
editData.SubmitLabel = "Submit Reply"
@ -714,9 +720,14 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
}
defer tx.Rollback(c.Context())
c.Req.ParseForm()
// TODO(ben): Validation
err = c.Req.ParseForm()
if err != nil {
return ErrorResponse(http.StatusBadRequest, oops.New(nil, "the form data was invalid"))
}
unparsed := c.Req.Form.Get("body")
if unparsed == "" {
return RejectRequest(c, "Your reply cannot be empty.")
}
newPostId, _ := CreateNewPost(c.Context(), tx, c.CurrentProject.ID, cd.ThreadID, models.ThreadTypeForumPost, c.CurrentUser.ID, &cd.PostID, unparsed, c.Req.Host)

View File

@ -120,7 +120,7 @@ func Index(c *RequestContext) ResponseData {
`,
currentUserId,
proj.ID,
[]models.ThreadType{models.ThreadTypeProjectArticle, models.ThreadTypeForumPost},
[]models.ThreadType{models.ThreadTypeProjectBlogPost, models.ThreadTypeForumPost},
maxPosts,
)
c.Perf.EndBlock()
@ -155,7 +155,7 @@ func Index(c *RequestContext) ResponseData {
}
featurable := (!proj.IsHMN() &&
projectPost.Post.ThreadType == models.ThreadTypeProjectArticle &&
projectPost.Post.ThreadType == models.ThreadTypeProjectBlogPost &&
projectPost.Thread.FirstID == projectPost.Post.ID &&
landingPageProject.FeaturedPost == nil)
@ -269,7 +269,7 @@ func Index(c *RequestContext) ResponseData {
LIMIT 1
`,
models.HMNProjectID,
models.ThreadTypeProjectArticle,
models.ThreadTypeProjectBlogPost,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post"))

View File

@ -9,7 +9,7 @@ import (
// NOTE(asaf): Please don't use this if you already know the kind of the post beforehand. Just call the appropriate build function.
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
switch post.ThreadType {
case models.ThreadTypeProjectArticle:
case models.ThreadTypeProjectBlogPost:
return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID)
case models.ThreadTypeForumPost:
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
@ -19,9 +19,9 @@ func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder
}
var PostTypeMap = map[models.ThreadType][]templates.PostType{
// { First post , Subsequent post }
models.ThreadTypeProjectArticle: {templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply},
// { First post , Subsequent post }
models.ThreadTypeProjectBlogPost: {templates.PostTypeBlogPost, templates.PostTypeBlogComment},
models.ThreadTypeForumPost: {templates.PostTypeForumThread, templates.PostTypeForumReply},
}
var PostTypePrefix = map[templates.PostType]string{

View File

@ -166,10 +166,16 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
mainRoutes.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
mainRoutes.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
mainRoutes.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
mainRoutes.GET(hmnurl.RegexBlogPostEdit, BlogPostEdit)
mainRoutes.POST(hmnurl.RegexBlogPostEdit, BlogPostEditSubmit)
mainRoutes.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
mainRoutes.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
mainRoutes.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
mainRoutes.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
mainRoutes.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
mainRoutes.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
mainRoutes.GET(hmnurl.RegexPodcast, PodcastIndex)
mainRoutes.GET(hmnurl.RegexPodcastEdit, PodcastEdit)

View File

@ -5,6 +5,6 @@ import (
)
var ThreadTypeDisplayNames = map[models.ThreadType]string{
models.ThreadTypeProjectArticle: "Blog",
models.ThreadTypeForumPost: "Forums",
models.ThreadTypeProjectBlogPost: "Blog",
models.ThreadTypeForumPost: "Forums",
}

View File

@ -241,7 +241,7 @@ func CreateNewPost(
`,
time.Now(),
threadId,
models.ThreadTypeForumPost,
threadType,
-1,
userId,
projectId,

View File

@ -11,9 +11,9 @@ import (
)
var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{
// { First post , Subsequent post }
models.ThreadTypeProjectArticle: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
// { First post , Subsequent post }
models.ThreadTypeProjectBlogPost: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
}
var TimelineItemClassMap = map[templates.TimelineType]string{

View File

@ -7,7 +7,7 @@ import (
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
switch kind {
case models.ThreadTypeProjectArticle:
case models.ThreadTypeProjectBlogPost:
return hmnurl.BuildBlog(projectSlug, 1)
case models.ThreadTypeForumPost:
return hmnurl.BuildForum(projectSlug, nil, 1)