Add forum replies
This commit is contained in:
parent
4ba175c5a5
commit
17f652191d
|
@ -19,11 +19,16 @@
|
|||
|
||||
{{ define "content" }}
|
||||
<div class="content-block ph3 ph0-ns">
|
||||
{{ if .ThreadTitle }}
|
||||
<h2>{{ .ThreadTitle }}</h2>
|
||||
{{ end }}
|
||||
<div class="flex flex-column flex-row-ns">
|
||||
<form id="form" action="{{ .SubmitUrl }}" method="post" class="flex-fair-ns">
|
||||
{{ csrftoken .Session }}
|
||||
|
||||
<input class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}"/>
|
||||
{{ if not .PostReplyingTo }}
|
||||
<input class="b w-100 mb1" name="title" type="text" placeholder="Post title..." value="{{ .PostTitle }}"/>
|
||||
{{ end }}
|
||||
{{/* TODO: Reintroduce the toolbar in a way that makes sense for Markdown */}}
|
||||
{{/*
|
||||
<div class="toolbar" id="toolbar">
|
||||
|
@ -68,6 +73,55 @@
|
|||
{% endif %}
|
||||
*/}}
|
||||
|
||||
{{ with .PostReplyingTo }}
|
||||
<h4 class="mt3">The post you're replying to:</h4>
|
||||
<div class="bg--dim pa3 br3">
|
||||
<div class="w-100 flex items-center">
|
||||
{{ if .Author }}
|
||||
<div class="w-20 mw3 w3">
|
||||
<!-- Mobile avatar -->
|
||||
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||
</div>
|
||||
<div class="pl3 flex flex-column">
|
||||
<div>
|
||||
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
|
||||
<!-- Mobile badges -->
|
||||
<div class="di ph1">
|
||||
{{ if .Author.IsStaff }}
|
||||
<div class="badge staff"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ if and .Author.Name (ne .Author.Name .Author.Username) }}
|
||||
<div class="c--dim f7"> {{ .Author.Name }} </div>
|
||||
{{ end }}
|
||||
<div class="c--dim f7">
|
||||
{{ timehtml (relativedate .PostDate) .PostDate }}
|
||||
{{ if .Editor }}
|
||||
<span class="pl3">
|
||||
Edited by
|
||||
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
|
||||
on {{ timehtml (absolutedate .EditDate) .EditDate }}
|
||||
{{ with .EditReason }}
|
||||
Reason: {{ . }}
|
||||
{{ end }}
|
||||
</span>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="username">Deleted member</div>
|
||||
<div class="avatar" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="w-100 pt3">
|
||||
<div class="contents overflow-x-auto">
|
||||
{{ .Content }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ end }}
|
||||
|
||||
{{/*
|
||||
|
||||
{% if context_reply_to %}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
{{ template "pagination.html" .Pagination }}
|
||||
</div>
|
||||
{{ range .Posts }}
|
||||
<div class="post background-even pa3 bbcode"> {{/* TODO: Dynamically switch between bbcode and markdown */}}
|
||||
<div class="post background-even pa3">
|
||||
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
|
||||
{{ if .Author }}
|
||||
<div class="fl w-20 mw3 dn-l w3">
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
|
@ -16,6 +18,7 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/parsing"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
"git.handmade.network/hmn/hmn/src/utils"
|
||||
"github.com/jackc/pgx/v4"
|
||||
)
|
||||
|
||||
type forumCategoryData struct {
|
||||
|
@ -35,6 +38,18 @@ type forumSubcategoryData struct {
|
|||
TotalThreads int
|
||||
}
|
||||
|
||||
type editorData struct {
|
||||
templates.BaseData
|
||||
SubmitUrl string
|
||||
PostTitle string
|
||||
PostBody string
|
||||
SubmitLabel string
|
||||
IsEditing bool // false if new post, true if updating existing one
|
||||
|
||||
ThreadTitle string
|
||||
PostReplyingTo *templates.Post
|
||||
}
|
||||
|
||||
func ForumCategory(c *RequestContext) ResponseData {
|
||||
const threadsPerPage = 25
|
||||
|
||||
|
@ -425,6 +440,9 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
func ForumPostRedirect(c *RequestContext) ResponseData {
|
||||
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||
// Consider compressing this later.
|
||||
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||
|
@ -509,15 +527,6 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
|
|||
), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
type editorData struct {
|
||||
templates.BaseData
|
||||
SubmitUrl string
|
||||
PostTitle string
|
||||
PostBody string
|
||||
SubmitLabel string
|
||||
IsEditing bool // false if new post, true if updating existing one
|
||||
}
|
||||
|
||||
func ForumNewThread(c *RequestContext) ResponseData {
|
||||
baseData := getBaseData(c)
|
||||
baseData.Title = "Create New Thread"
|
||||
|
@ -569,17 +578,6 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
sticky = true
|
||||
}
|
||||
|
||||
parsed := parsing.ParsePostInput(unparsed, parsing.RealMarkdown)
|
||||
now := time.Now()
|
||||
ip := net.ParseIP(c.Req.RemoteAddr)
|
||||
|
||||
const previewMaxLength = 100
|
||||
parsedPlaintext := parsing.ParsePostInput(unparsed, parsing.PlaintextMarkdown)
|
||||
preview := parsedPlaintext
|
||||
if len(preview) > previewMaxLength-1 {
|
||||
preview = preview[:previewMaxLength-1] + "…"
|
||||
}
|
||||
|
||||
// Create thread
|
||||
var threadId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
|
@ -598,58 +596,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
panic(oops.New(err, "failed to create thread"))
|
||||
}
|
||||
|
||||
// Create post
|
||||
var postId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
now,
|
||||
currentCatId,
|
||||
threadId,
|
||||
preview,
|
||||
-1,
|
||||
c.CurrentUser.ID,
|
||||
models.CatKindForum,
|
||||
c.CurrentProject.ID,
|
||||
).Scan(&postId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post"))
|
||||
}
|
||||
|
||||
// Create post version
|
||||
var versionId int
|
||||
err = tx.QueryRow(c.Context(),
|
||||
`
|
||||
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`,
|
||||
postId,
|
||||
unparsed,
|
||||
parsed,
|
||||
ip,
|
||||
now,
|
||||
).Scan(&versionId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post version"))
|
||||
}
|
||||
|
||||
// Update post with version id
|
||||
_, err = tx.Exec(c.Context(),
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET current_id = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
versionId,
|
||||
postId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to set current post version"))
|
||||
}
|
||||
postId, _ := createForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host)
|
||||
|
||||
// Update thread with post id
|
||||
_, err = tx.Exec(c.Context(),
|
||||
|
@ -676,6 +623,191 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
|||
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func ForumPostReply(c *RequestContext) ResponseData {
|
||||
// TODO(compression): This logic for fetching posts by thread id / post id is gonna
|
||||
// show up in a lot of places. It's used multiple times for forums, and also for blogs.
|
||||
// Consider compressing this later.
|
||||
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||
if !valid {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
requestedThreadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||
if err != nil {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
requestedPostId, err := strconv.Atoi(c.PathParams["postid"])
|
||||
if err != nil {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch post ids for thread")
|
||||
// TODO: Scope this down to just what you need
|
||||
type postQuery struct {
|
||||
Thread models.Thread `db:"thread"`
|
||||
Post models.Post `db:"post"`
|
||||
CurrentVersion models.PostVersion `db:"ver"`
|
||||
Author *models.User `db:"author"`
|
||||
Editor *models.User `db:"editor"`
|
||||
}
|
||||
postQueryResult, err := db.QueryOne(c.Context(), c.Conn, postQuery{},
|
||||
`
|
||||
SELECT $columns
|
||||
FROM
|
||||
handmade_thread AS thread
|
||||
JOIN handmade_post AS post ON post.thread_id = thread.id
|
||||
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
||||
LEFT JOIN auth_user AS author ON post.author_id = author.id
|
||||
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||
WHERE
|
||||
post.category_id = $1
|
||||
AND post.thread_id = $2
|
||||
AND post.id = $3
|
||||
AND NOT post.deleted
|
||||
ORDER BY postdate
|
||||
`,
|
||||
currentCatId,
|
||||
requestedThreadId,
|
||||
requestedPostId,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrNoMatchingRows) {
|
||||
return FourOhFour(c)
|
||||
} else {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch reply post"))
|
||||
}
|
||||
}
|
||||
result := postQueryResult.(*postQuery)
|
||||
|
||||
baseData := getBaseData(c)
|
||||
baseData.Title = fmt.Sprintf("Replying to \"%s\" | %s", result.Thread.Title, *categoryTree[currentCatId].Name)
|
||||
baseData.MathjaxEnabled = true
|
||||
// TODO(ben): Set breadcrumbs
|
||||
|
||||
templatePost := templates.PostToTemplate(&result.Post, result.Author, c.Theme)
|
||||
templatePost.AddContentVersion(result.CurrentVersion, result.Editor)
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("editor.html", editorData{
|
||||
BaseData: baseData,
|
||||
SubmitUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), requestedThreadId, requestedPostId),
|
||||
SubmitLabel: "Submit Reply",
|
||||
|
||||
ThreadTitle: result.Thread.Title,
|
||||
PostReplyingTo: &templatePost,
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
||||
func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
||||
tx, err := c.Conn.Begin(c.Context())
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer tx.Rollback(c.Context())
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch category tree")
|
||||
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
||||
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
currentCatId, valid := validateSubforums(lineageBuilder, c.CurrentProject, c.PathParams["cats"])
|
||||
if !valid {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
threadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||
if err != nil {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
c.Req.ParseForm()
|
||||
|
||||
unparsed := c.Req.Form.Get("body")
|
||||
|
||||
postId, _ := createForumPostAndVersion(c.Context(), tx, currentCatId, threadId, c.CurrentUser.ID, c.CurrentProject.ID, unparsed, c.Req.Host)
|
||||
|
||||
err = tx.Commit(c.Context())
|
||||
if err != nil {
|
||||
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
|
||||
}
|
||||
|
||||
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(currentCatId), threadId, postId)
|
||||
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func createForumPostAndVersion(ctx context.Context, tx pgx.Tx, catId, threadId, userId, projectId int, unparsedContent string, ipString string) (postId, versionId int) {
|
||||
parsed := parsing.ParsePostInput(unparsedContent, parsing.RealMarkdown)
|
||||
now := time.Now()
|
||||
ip := net.ParseIP(ipString)
|
||||
|
||||
const previewMaxLength = 100
|
||||
parsedPlaintext := parsing.ParsePostInput(unparsedContent, parsing.PlaintextMarkdown)
|
||||
preview := parsedPlaintext
|
||||
if len(preview) > previewMaxLength-1 {
|
||||
preview = preview[:previewMaxLength-1] + "…"
|
||||
}
|
||||
|
||||
// Create post
|
||||
err := tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_post (postdate, category_id, thread_id, preview, current_id, author_id, category_kind, project_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
RETURNING id
|
||||
`,
|
||||
now,
|
||||
catId,
|
||||
threadId,
|
||||
preview,
|
||||
-1,
|
||||
userId,
|
||||
models.CatKindForum,
|
||||
projectId,
|
||||
).Scan(&postId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post"))
|
||||
}
|
||||
|
||||
// Create post version
|
||||
err = tx.QueryRow(ctx,
|
||||
`
|
||||
INSERT INTO handmade_postversion (post_id, text_raw, text_parsed, ip, date)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING id
|
||||
`,
|
||||
postId,
|
||||
unparsedContent,
|
||||
parsed,
|
||||
ip,
|
||||
now,
|
||||
).Scan(&versionId)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to create post version"))
|
||||
}
|
||||
|
||||
// Update post with version id
|
||||
_, err = tx.Exec(ctx,
|
||||
`
|
||||
UPDATE handmade_post
|
||||
SET current_id = $1
|
||||
WHERE id = $2
|
||||
`,
|
||||
versionId,
|
||||
postId,
|
||||
)
|
||||
if err != nil {
|
||||
panic(oops.New(err, "failed to set current post version"))
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func validateSubforums(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, catPath string) (int, bool) {
|
||||
if project.ForumID == nil {
|
||||
return -1, false
|
||||
|
|
|
@ -153,11 +153,13 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
mainRoutes.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
||||
|
||||
// NOTE(asaf): Any-project routes:
|
||||
mainRoutes.Handle([]string{http.MethodGet, http.MethodPost}, hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
||||
mainRoutes.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
||||
mainRoutes.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
||||
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
|
||||
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
|
||||
mainRoutes.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||
mainRoutes.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
|
||||
mainRoutes.POST(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReplySubmit))
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
|
||||
|
||||
|
|
Loading…
Reference in New Issue