From d6481ab4211f17496f2fbbe402ab04b863d223e1 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Thu, 6 May 2021 00:57:14 -0500 Subject: [PATCH] Get forum threads mostly done Still need to do breadcrumbs, but that applies to forum categories too actually. --- src/hmnurl/urls.go | 59 ++++++++++---- src/templates/mapping.go | 37 +++++++-- src/templates/src/forum_thread.html | 31 ++++++-- src/templates/src/include/header.html | 5 ++ src/templates/src/include/post_list_item.html | 2 +- .../src/include/thread_list_item.html | 4 +- src/templates/src/landing.html | 2 +- src/templates/src/layouts/base.html | 2 +- src/templates/templates.go | 7 ++ src/templates/types.go | 9 ++- src/website/forums.go | 77 +++++++++++++++---- src/website/urls.go | 11 +-- 12 files changed, 190 insertions(+), 56 deletions(-) diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index f057212..5e6cbf3 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -8,67 +8,67 @@ import ( "git.handmade.network/hmn/hmn/src/oops" ) -var RegexHomepage *regexp.Regexp = regexp.MustCompile("^/$") +var RegexHomepage = regexp.MustCompile("^/$") func BuildHomepage() string { return Url("/", nil) } -var RegexLogin *regexp.Regexp = regexp.MustCompile("^/login$") +var RegexLogin = regexp.MustCompile("^/login$") func BuildLogin() string { return Url("/login", nil) } -var RegexLogout *regexp.Regexp = regexp.MustCompile("^/logout$") +var RegexLogout = regexp.MustCompile("^/logout$") func BuildLogout() string { return Url("/logout", nil) } -var RegexManifesto *regexp.Regexp = regexp.MustCompile("^/manifesto$") +var RegexManifesto = regexp.MustCompile("^/manifesto$") func BuildManifesto() string { return Url("/manifesto", nil) } -var RegexAbout *regexp.Regexp = regexp.MustCompile("^/about$") +var RegexAbout = regexp.MustCompile("^/about$") func BuildAbout() string { return Url("/about", nil) } -var RegexCodeOfConduct *regexp.Regexp = regexp.MustCompile("^/code-of-conduct$") +var RegexCodeOfConduct = regexp.MustCompile("^/code-of-conduct$") func BuildCodeOfConduct() string { return Url("/code-of-conduct", nil) } -var RegexCommunicationGuidelines *regexp.Regexp = regexp.MustCompile("^/communication-guidelines$") +var RegexCommunicationGuidelines = regexp.MustCompile("^/communication-guidelines$") func BuildCommunicationGuidelines() string { return Url("/communication-guidelines", nil) } -var RegexContactPage *regexp.Regexp = regexp.MustCompile("^/contact$") +var RegexContactPage = regexp.MustCompile("^/contact$") func BuildContactPage() string { return Url("/contact", nil) } -var RegexMonthlyUpdatePolicy *regexp.Regexp = regexp.MustCompile("^/monthly-update-policy$") +var RegexMonthlyUpdatePolicy = regexp.MustCompile("^/monthly-update-policy$") func BuildMonthlyUpdatePolicy() string { return Url("/monthly-update-policy", nil) } -var RegexProjectSubmissionGuidelines *regexp.Regexp = regexp.MustCompile("^/project-guidelines$") +var RegexProjectSubmissionGuidelines = regexp.MustCompile("^/project-guidelines$") func BuildProjectSubmissionGuidelines() string { return Url("/project-guidelines", nil) } -var RegexFeed *regexp.Regexp = regexp.MustCompile(`^/feed(/(?P.+)?)?$`) +var RegexFeed = regexp.MustCompile(`^/feed(/(?P.+)?)?$`) func BuildFeed() string { return Url("/feed", nil) @@ -84,7 +84,7 @@ func BuildFeedWithPage(page int) string { return Url("/feed/"+strconv.Itoa(page), nil) } -var RegexForumThread *regexp.Regexp = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`) +var RegexForumThread = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`) func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string { if page < 1 { @@ -114,7 +114,7 @@ func BuildForumThread(projectSlug string, subforums []string, threadId int, page return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexForumCategory *regexp.Regexp = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`) +var RegexForumCategory = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`) func BuildForumCategory(projectSlug string, subforums []string, page int) string { if page < 1 { @@ -142,7 +142,7 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexForumPost *regexp.Regexp = regexp.MustCompile(``) // TODO(asaf): Complete this and test it +var RegexForumPost = regexp.MustCompile(``) // TODO(asaf): Complete this and test it func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string { var builder strings.Builder @@ -166,13 +166,38 @@ func BuildForumPost(projectSlug string, subforums []string, threadId int, postId return ProjectUrl(builder.String(), nil, projectSlug) } -var RegexProjectCSS *regexp.Regexp = regexp.MustCompile("^/assets/project.css$") +var RegexForumPostDelete = regexp.MustCompile(``) // TODO + +func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string { + return BuildForumPost(projectSlug, subforums, threadId, postId) + "/delete" +} + +var RegexForumPostEdit = regexp.MustCompile(``) // TODO + +func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string { + return BuildForumPost(projectSlug, subforums, threadId, postId) + "/edit" +} + +var RegexForumPostReply = regexp.MustCompile(``) // TODO(asaf): Ha ha! I, Ben, have played a trick on you, and forced you to do this regex as well! + +// TODO: It's kinda weird that we have "replies" to a specific post. That's not how the data works. I guess this just affects what you see as the "post you're replying to" on the post composer page? +func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string { + return BuildForumPost(projectSlug, subforums, threadId, postId) + "/reply" +} + +var RegexForumPostQuote = regexp.MustCompile(``) // TODO + +func BuildForumPostQuote(projectSlug string, subforums []string, threadId int, postId int) string { + return BuildForumPost(projectSlug, subforums, threadId, postId) + "/quote" +} + +var RegexProjectCSS = regexp.MustCompile("^/assets/project.css$") func BuildProjectCSS(color string) string { return Url("/assets/project.css", []Q{{"color", color}}) } -var RegexPublic *regexp.Regexp = regexp.MustCompile("^/public/.+$") +var RegexPublic = regexp.MustCompile("^/public/.+$") func BuildPublic(filepath string) string { filepath = strings.Trim(filepath, "/") @@ -193,4 +218,4 @@ func BuildPublic(filepath string) string { return Url(builder.String(), nil) } -var RegexCatchAll *regexp.Regexp = regexp.MustCompile("") +var RegexCatchAll = regexp.MustCompile("") diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 5255367..4dcab5e 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -2,6 +2,7 @@ package templates import ( "html/template" + "net" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -15,25 +16,39 @@ func PostToTemplate(p *models.Post, author *models.User) Post { } return Post{ - ID: p.ID, - Url: "nope", // TODO + ID: p.ID, + + // Urls not set here. See AddUrls. Preview: p.Preview, ReadOnly: p.ReadOnly, Author: authorUser, - // No content. Do it yourself if you care. + // No content. A lot of the time we don't have this handy and don't need it. See AddContentVersion. PostDate: p.PostDate, IP: p.IP.String(), } } -func PostToTemplateWithContent(p *models.Post, author *models.User, content string) Post { - post := PostToTemplate(p, author) - post.Content = template.HTML(content) +func (p *Post) AddContentVersion(ver models.PostVersion, editor *models.User) { + p.Content = template.HTML(ver.TextParsed) - return post + if editor != nil { + editorTmpl := UserToTemplate(editor) + p.Editor = &editorTmpl + p.EditDate = ver.EditDate + p.EditIP = maybeIp(ver.EditIP) + p.EditReason = ver.EditReason + } +} + +func (p *Post) AddUrls(projectSlug string, subforums []string, threadId int, postId int) { + p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId) + p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId) + p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId) + p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId) + p.QuoteUrl = hmnurl.BuildForumPostQuote(projectSlug, subforums, threadId, postId) } func ProjectToTemplate(p *models.Project) Project { @@ -103,3 +118,11 @@ func maybeString(s *string) string { } return *s } + +func maybeIp(ip *net.IPNet) string { + if ip == nil { + return "" + } + + return ip.String() +} diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html index 034ad95..c2b34ae 100644 --- a/src/templates/src/forum_thread.html +++ b/src/templates/src/forum_thread.html @@ -2,6 +2,10 @@ {{ define "content" }}
+
+ ← Back to index + {{ template "pagination.html" .Pagination }} +
{{ range .Posts }}
{{/* TODO: Dynamically switch between bbcode and markdown */}}
@@ -62,28 +66,28 @@ {{ if $.User }}
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }} -   -   +   +   {{ end }} {{ if or (not $.Thread.Locked) $.User.IsStaff }} {{ if $.Thread.Locked }} WARNING: locked thread - use power responsibly! {{ end }} -   - +   + {{ end }}
{{ end }}
{{ $.Thread.Title }}
- {{ relativedate .PostDate }} ago + {{ timehtml (relativedate .PostDate) .PostDate }} {{ if .Editor }} Edited by {{ coalesce .Editor.Name .Editor.Username }} {{ if and $.User.IsStaff .EditIP }}[{{ .EditIP }}]{{ end }} - on {{ .EditDate }} + on {{ timehtml (absolutedate .EditDate) .EditDate }} {{ with .EditReason }} Reason: {{ . }} {{ end }} @@ -108,5 +112,20 @@
{{ end }} +
+
+ ← Back to index + {{ if .Thread.Locked }} + Thread is locked. + {{ else if .User }} + ⤷ Reply to Thread + {{ else }} + Log in to reply + {{ end }} +
+
+ {{ template "pagination.html" .Pagination }} +
+
{{ end }} diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 0e31e9b..53ec3fc 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -87,5 +87,10 @@ loginPopup.classList.toggle("open"); } } + + for (const time of document.querySelectorAll('time')) { + const d = new Date(Date.parse(time.dateTime)); + time.title = d.toLocaleString(); + } }); diff --git a/src/templates/src/include/post_list_item.html b/src/templates/src/include/post_list_item.html index 5a9fda0..a6f4e27 100644 --- a/src/templates/src/include/post_list_item.html +++ b/src/templates/src/include/post_list_item.html @@ -15,7 +15,7 @@ It should be called with PostListItem.
- {{ .User.Name }}{{ relativedate .Date }} + {{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }}
{{ with .Content }}
diff --git a/src/templates/src/include/thread_list_item.html b/src/templates/src/include/thread_list_item.html index df05fe9..cad6dcc 100644 --- a/src/templates/src/include/thread_list_item.html +++ b/src/templates/src/include/thread_list_item.html @@ -15,7 +15,7 @@ It should be called with ThreadListItem.
- {{ .FirstUser.Name }}{{ relativedate .FirstDate }} + {{ .FirstUser.Name }} — {{ timehtml (relativedate .FirstDate) .FirstDate }}
{{ with .Content }}
@@ -26,7 +26,7 @@ It should be called with ThreadListItem.
-
Last post {{ relativedate .LastDate }}
+
Last post {{ timehtml (relativedate .LastDate) .LastDate }}
{{ .LastUser.Name }}
diff --git a/src/templates/src/landing.html b/src/templates/src/landing.html index 2b76876..e962904 100644 --- a/src/templates/src/landing.html +++ b/src/templates/src/landing.html @@ -271,7 +271,7 @@
- {{ .User.Name }}{{ relativedate .Date }} + {{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }}
diff --git a/src/templates/src/layouts/base.html b/src/templates/src/layouts/base.html index 1cd0030..35d36d0 100644 --- a/src/templates/src/layouts/base.html +++ b/src/templates/src/layouts/base.html @@ -12,7 +12,7 @@ {{ end }} {{ end }} {{ if .Title }} - {{ .Title }} | Handmade Network + {{ .Title }} | Handmade Network {{/* TODO: Some parts of the site replace "Handmade Network" with other things like "4coder Forums". */}} {{ else }} Handmade Network {{ end }} diff --git a/src/templates/templates.go b/src/templates/templates.go index 543f829..a9403c8 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -67,6 +67,9 @@ func names(ts []*template.Template) []string { } var HMNTemplateFuncs = template.FuncMap{ + "absolutedate": func(t time.Time) string { + return t.Format("January 2, 2006, 3:04pm") + }, "alpha": func(alpha float64, color noire.Color) noire.Color { color.Alpha = alpha return color @@ -157,6 +160,10 @@ var HMNTemplateFuncs = template.FuncMap{ "staticthemenobust": func(theme string, filepath string) string { return hmnurl.StaticThemeUrl(filepath, theme, nil) }, + "timehtml": func(formatted string, t time.Time) template.HTML { + iso := t.Format(time.RFC3339) + return template.HTML(fmt.Sprintf(``, iso, formatted)) + }, "url": func(url string) string { return hmnurl.Url(url, nil) }, diff --git a/src/templates/types.go b/src/templates/types.go index 9725294..149a533 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -26,8 +26,13 @@ type Thread struct { } type Post struct { - ID int - Url string + ID int + + Url string + DeleteUrl string + EditUrl string + ReplyUrl string + QuoteUrl string Preview string ReadOnly bool diff --git a/src/website/forums.go b/src/website/forums.go index 55aac4f..258ba56 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -15,6 +15,7 @@ import ( "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/utils" "github.com/jackc/pgx/v4/pgxpool" ) @@ -262,7 +263,7 @@ func ForumCategory(c *RequestContext) ResponseData { baseData := getBaseData(c) baseData.Title = c.CurrentProject.Name + " Forums" - baseData.Breadcrumbs = []templates.Breadcrumb{ + baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subcategories. { Name: c.CurrentProject.Name, Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Slug), @@ -299,13 +300,17 @@ func ForumCategory(c *RequestContext) ResponseData { type forumThreadData struct { templates.BaseData + Thread templates.Thread Posts []templates.Post + + CategoryUrl string + ReplyUrl string + Pagination templates.Pagination } func ForumThread(c *RequestContext) ResponseData { const postsPerPage = 15 - // TODO(asaf): Verify that the requested thread is not deleted, and only fetch non-deleted posts. threadId, err := strconv.Atoi(c.PathParams["threadid"]) if err != nil { @@ -319,10 +324,16 @@ func ForumThread(c *RequestContext) ResponseData { irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{}, ` SELECT $columns - FROM handmade_thread AS thread - WHERE thread.id = $1 + FROM + handmade_thread AS thread + JOIN handmade_category AS cat ON cat.id = thread.category_id + WHERE + thread.id = $1 + AND NOT thread.deleted + AND cat.project_id = $2 `, threadId, + c.CurrentProject.ID, ) c.Perf.EndBlock() if err != nil { @@ -336,18 +347,46 @@ func ForumThread(c *RequestContext) ResponseData { categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID) - page, numPages, ok := getPageInfo(c.PathParams["page"], 100, postsPerPage) // TODO: Not 100 + c.Perf.StartBlock("SQL", "Fetch category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + subforums := lineageBuilder.GetLineageSlugs(thread.CategoryID)[1:] + c.Perf.EndBlock() + + numPosts, err := db.QueryInt(c.Context(), c.Conn, + ` + SELECT COUNT(*) + FROM handmade_post + WHERE + thread_id = $1 + AND NOT deleted + `, + thread.ID, + ) + if err != nil { + panic(oops.New(err, "failed to get count of posts for thread")) + } + page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, postsPerPage) if !ok { urlNoPage := ThreadUrl(thread, models.CatKindForum, categoryUrls[thread.CategoryID]) return c.Redirect(urlNoPage, http.StatusSeeOther) } - _ = numPages // TODO + pagination := templates.Pagination{ + Current: page, + Total: numPages, + + FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, 1), + LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, numPages), + NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, utils.IntClamp(1, page+1, numPages)), + PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, subforums, thread.ID, utils.IntClamp(1, page-1, numPages)), + } c.Perf.StartBlock("SQL", "Fetch posts") type postsQueryResult struct { - Post models.Post `db:"post"` - Content string `db:"ver.text_parsed"` - Author *models.User `db:"author"` + Post models.Post `db:"post"` + Ver models.PostVersion `db:"ver"` + Author *models.User `db:"author"` + Editor *models.User `db:"editor"` } itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{}, ` @@ -356,8 +395,10 @@ func ForumThread(c *RequestContext) ResponseData { handmade_post AS post 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.thread_id = $1 + AND NOT post.deleted ORDER BY postdate LIMIT $2 OFFSET $3 `, @@ -374,18 +415,26 @@ func ForumThread(c *RequestContext) ResponseData { var posts []templates.Post for _, irow := range itPosts.ToSlice() { row := irow.(*postsQueryResult) - posts = append(posts, templates.PostToTemplateWithContent(&row.Post, row.Author, row.Content)) + + post := templates.PostToTemplate(&row.Post, row.Author) + post.AddContentVersion(row.Ver, row.Editor) + post.AddUrls(c.CurrentProject.Slug, subforums, thread.ID, post.ID) + + posts = append(posts, post) } baseData := getBaseData(c) - // TODO(asaf): Replace page title with thread title + baseData.Title = thread.Title // TODO(asaf): Set breadcrumbs var res ResponseData err = res.WriteTemplate("forum_thread.html", forumThreadData{ - BaseData: baseData, - Thread: templates.ThreadToTemplate(&thread), - Posts: posts, + BaseData: baseData, + Thread: templates.ThreadToTemplate(&thread), + Posts: posts, + CategoryUrl: categoryUrls[thread.CategoryID], + ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, subforums, thread.ID, *thread.FirstID), + Pagination: pagination, }, c.Perf) if err != nil { panic(err) diff --git a/src/website/urls.go b/src/website/urls.go index b707425..f672e84 100644 --- a/src/website/urls.go +++ b/src/website/urls.go @@ -44,9 +44,10 @@ func GetProjectCategoryUrls(ctx context.Context, conn *pgxpool.Pool, projectId . JOIN handmade_project AS project ON project.id = cat.project_id WHERE project.id = ANY ($1) - AND cat.kind != 6 - `, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check + AND cat.kind != $2 + `, // TODO(asaf): Clean up the db and remove the cat.kind != library resource check projectId, + models.CatKindLibraryResource, ) if err != nil { panic(err) @@ -94,13 +95,13 @@ func makeCategoryUrls(rows []interface{}) map[int]string { } func CategoryUrl(projectSlug string, cats ...*models.Category) string { - catNames := make([]string, 0, len(cats)) + catSlugs := make([]string, 0, len(cats)) for _, cat := range cats { - catNames = append(catNames, *cat.Name) + catSlugs = append(catSlugs, *cat.Slug) } switch cats[0].Kind { case models.CatKindForum: - return hmnurl.BuildForumCategory(projectSlug, catNames[1:], 1) + return hmnurl.BuildForumCategory(projectSlug, catSlugs[1:], 1) default: return "" }