From 71a46ba5a18c9fba4af3413700e445e6f421b967 Mon Sep 17 00:00:00 2001 From: Ben Visness Date: Mon, 3 May 2021 20:59:45 -0500 Subject: [PATCH] Get forum threads mostly implemented Still several TODOs in the handler and templates --- .../2021-05-04T001258Z_DeleteBadUnreadInfo.go | 59 +++++++++ ...-05-04T002952Z_AddUnreadInfoConstraints.go | 52 ++++++++ src/templates/mapping.go | 29 ++++- src/templates/src/forum_thread.html | 112 ++++++++++++++++++ src/templates/src/project.css | 4 +- src/templates/types.go | 20 +++- src/website/forums.go | 95 +++++++++++++++ src/website/pagination.go | 33 ++++++ src/website/routes.go | 13 +- 9 files changed, 402 insertions(+), 15 deletions(-) create mode 100644 src/migration/migrations/2021-05-04T001258Z_DeleteBadUnreadInfo.go create mode 100644 src/migration/migrations/2021-05-04T002952Z_AddUnreadInfoConstraints.go create mode 100644 src/templates/src/forum_thread.html create mode 100644 src/website/pagination.go diff --git a/src/migration/migrations/2021-05-04T001258Z_DeleteBadUnreadInfo.go b/src/migration/migrations/2021-05-04T001258Z_DeleteBadUnreadInfo.go new file mode 100644 index 00000000..8077ccfc --- /dev/null +++ b/src/migration/migrations/2021-05-04T001258Z_DeleteBadUnreadInfo.go @@ -0,0 +1,59 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(DeleteBadUnreadInfo{}) +} + +type DeleteBadUnreadInfo struct{} + +func (m DeleteBadUnreadInfo) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 5, 4, 0, 12, 58, 0, time.UTC)) +} + +func (m DeleteBadUnreadInfo) Name() string { + return "DeleteBadUnreadInfo" +} + +func (m DeleteBadUnreadInfo) Description() string { + return "Delete invalid tlri and clri" +} + +func (m DeleteBadUnreadInfo) Up(ctx context.Context, tx pgx.Tx) error { + threadNullsResult, err := tx.Exec(ctx, ` + DELETE FROM handmade_threadlastreadinfo + WHERE + lastread IS NULL + OR thread_id IS NULL; + `) + if err != nil { + return oops.New(err, "failed to delete thread entries with null fields") + } + fmt.Printf("Deleted %d thread entries with null fields\n", threadNullsResult.RowsAffected()) + + catNullsResult, err := tx.Exec(ctx, ` + DELETE FROM handmade_categorylastreadinfo + WHERE + lastread IS NULL + OR category_id IS NULL; + `) + if err != nil { + return oops.New(err, "failed to delete category entries with null fields") + } + fmt.Printf("Deleted %d category entries with null fields\n", catNullsResult.RowsAffected()) + + return nil +} + +func (m DeleteBadUnreadInfo) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/migration/migrations/2021-05-04T002952Z_AddUnreadInfoConstraints.go b/src/migration/migrations/2021-05-04T002952Z_AddUnreadInfoConstraints.go new file mode 100644 index 00000000..0d97c278 --- /dev/null +++ b/src/migration/migrations/2021-05-04T002952Z_AddUnreadInfoConstraints.go @@ -0,0 +1,52 @@ +package migrations + +import ( + "context" + "time" + + "git.handmade.network/hmn/hmn/src/migration/types" + "git.handmade.network/hmn/hmn/src/oops" + "github.com/jackc/pgx/v4" +) + +func init() { + registerMigration(AddUnreadInfoConstraints{}) +} + +type AddUnreadInfoConstraints struct{} + +func (m AddUnreadInfoConstraints) Version() types.MigrationVersion { + return types.MigrationVersion(time.Date(2021, 5, 4, 0, 29, 52, 0, time.UTC)) +} + +func (m AddUnreadInfoConstraints) Name() string { + return "AddUnreadInfoConstraints" +} + +func (m AddUnreadInfoConstraints) Description() string { + return "Add more constraints to the unread info tables" +} + +func (m AddUnreadInfoConstraints) Up(ctx context.Context, tx pgx.Tx) error { + _, err := tx.Exec(ctx, ` + ALTER TABLE handmade_threadlastreadinfo + ALTER lastread SET NOT NULL, + ALTER thread_id SET NOT NULL, + DROP category_id, + ADD UNIQUE (thread_id, user_id); + + ALTER TABLE handmade_categorylastreadinfo + ALTER lastread SET NOT NULL, + ALTER category_id SET NOT NULL, + ADD UNIQUE (category_id, user_id); + `) + if err != nil { + return oops.New(err, "failed to add constraints") + } + + return nil +} + +func (m AddUnreadInfoConstraints) Down(ctx context.Context, tx pgx.Tx) error { + panic("Implement me") +} diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 18677523..fea27079 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -1,25 +1,37 @@ package templates import ( + "html/template" + "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" ) -func PostToTemplate(p *models.Post, author models.User) Post { +func PostToTemplate(p *models.Post, author *models.User) Post { + var authorUser *User + if author != nil { + authorTmpl := UserToTemplate(author) + authorUser = &authorTmpl + } + return Post{ - Author: UserToTemplate(&author), + ID: p.ID, + Url: "nope", // TODO + Preview: p.Preview, ReadOnly: p.ReadOnly, + Author: authorUser, // No content. Do it yourself if you care. + PostDate: p.PostDate, IP: p.IP.String(), } } -func PostToTemplateWithContent(p *models.Post, author models.User, content string) Post { +func PostToTemplateWithContent(p *models.Post, author *models.User, content string) Post { post := PostToTemplate(p, author) - post.Content = content + post.Content = template.HTML(content) return post } @@ -40,6 +52,14 @@ func ProjectToTemplate(p *models.Project) Project { } } +func ThreadToTemplate(t *models.Thread) Thread { + return Thread{ + Title: t.Title, + Locked: t.Locked, + Sticky: t.Sticky, + } +} + func UserToTemplate(u *models.User) User { // TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function. @@ -54,6 +74,7 @@ func UserToTemplate(u *models.User) User { } return User{ + ID: u.ID, Username: u.Username, Email: u.Email, IsSuperuser: u.IsSuperuser, diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html new file mode 100644 index 00000000..034ad950 --- /dev/null +++ b/src/templates/src/forum_thread.html @@ -0,0 +1,112 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+ {{ range .Posts }} +
{{/* TODO: Dynamically switch between bbcode and markdown */}} +
+ {{ if .Author }} +
+ +
+
+
+
+ {{ .Author.Username }} {{/* TODO: Text scale stuff? Seems unnecessary. */}} + +
+ {{ if .Author.IsStaff }} +
+ {{ end }} +
+
+ {{ if and .Author.Name (ne .Author.Name .Author.Username) }} +
{{ .Author.Name }}
+ {{ end }} + +
+
+
+ {{/* TODO: Aggregate user data +
+ {{ post.author.posts }} posts + {% if post.author.public_projects.values|length > 0 %} + / {{ post.author.public_projects.values|length }} project{%if post.author.public_projects.values|length > 1 %}s{% endif %} + {% endif %} +
*/}} + +
+ {{ if .Author.IsStaff }} +
+ {{ end }} +
+
+ {{ if .Author.Blurb }} + {{ .Author.Blurb }} {{/* TODO: Linebreaks? */}} + {{ else if .Author.Bio }} + {{ .Author.Bio }} + {{ end }} +
+
+ {{ else }} +
Deleted member
+
+ {{ end }} +
+
+
+
+ + #{{ .ID }} + + {{ 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 + {{ if .Editor }} + + Edited by + {{ coalesce .Editor.Name .Editor.Username }} + {{ if and $.User.IsStaff .EditIP }}[{{ .EditIP }}]{{ end }} + on {{ .EditDate }} + {{ with .EditReason }} + Reason: {{ . }} + {{ end }} + + {{ end }} + {{ if $.User }} + {{ if $.User.IsStaff }} + [{{ .IP }}] + {{ end }} + {{ end }} +
+
+
+ {{ .Content }} +
+ {{/* {% if post.author.signature|length %} +

+ {{ post.author.signature|bbdecode|safe }} +
+ {% endif %} */}} +
+
+
+ {{ end }} +
+{{ end }} diff --git a/src/templates/src/project.css b/src/templates/src/project.css index aabf49cf..8d6f656c 100644 --- a/src/templates/src/project.css +++ b/src/templates/src/project.css @@ -43,7 +43,7 @@ a:hover, button:hover, .button:hover, input[type=button]:hover, input[type=submi } .post-list-bg-odd:nth-of-type(odd) { - background-color: {{ .PostListBgColor }}; + background-color: {{ .PostBgColor }}; } /* @@ -96,7 +96,7 @@ all of this CSS. {% endif %} */ :root { - --background-even-background: {{ eq .Theme "dark" | ternary (lightness 0.15 $c) (lightness 0.95 $c) | color2css }}; + --background-even-background: {{ .PostBgColor }}; } /* Assets */ diff --git a/src/templates/types.go b/src/templates/types.go index ce8e6eb2..9725294c 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -1,6 +1,9 @@ package templates -import "time" +import ( + "html/template" + "time" +) type BaseData struct { Title string @@ -23,11 +26,20 @@ type Thread struct { } type Post struct { - Author User + ID int + Url string + Preview string ReadOnly bool - Content string + Author *User + Content template.HTML + PostDate time.Time + + Editor *User + EditDate time.Time + EditIP string + EditReason string IP string } @@ -47,6 +59,7 @@ type Project struct { } type User struct { + ID int Username string Email string IsSuperuser bool @@ -54,6 +67,7 @@ type User struct { Name string Blurb string + Bio string Signature string AvatarUrl string ProfileUrl string diff --git a/src/website/forums.go b/src/website/forums.go index 8397b2c7..b8c6e911 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -2,6 +2,7 @@ package website import ( "context" + "errors" "fmt" "math" "net/http" @@ -284,6 +285,100 @@ func ForumCategory(c *RequestContext) ResponseData { return res } +type forumThreadData struct { + templates.BaseData + Thread templates.Thread + Posts []templates.Post +} + +func ForumThread(c *RequestContext) ResponseData { + const postsPerPage = 15 + + threadId, err := strconv.Atoi(c.PathParams["threadid"]) + if err != nil { + return FourOhFour(c) + } + + c.Perf.StartBlock("SQL", "Fetch current thread") + type threadQueryResult struct { + Thread models.Thread `db:"thread"` + } + irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{}, + ` + SELECT $columns + FROM handmade_thread AS thread + WHERE thread.id = $1 + `, + threadId, + ) + c.Perf.EndBlock() + if err != nil { + if errors.Is(err, db.ErrNoMatchingRows) { + return FourOhFour(c) + } else { + panic(err) + } + } + thread := irow.(*threadQueryResult).Thread + + categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID) + + page, numPages, ok := getPageInfo(c.PathParams["page"], 100, postsPerPage) // TODO: Not 100 + if !ok { + urlNoPage := ThreadUrl(thread, models.CatKindForum, categoryUrls[thread.CategoryID]) + return c.Redirect(urlNoPage, http.StatusSeeOther) + } + _ = numPages // TODO + + 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"` + } + itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{}, + ` + SELECT $columns + FROM + 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 + WHERE + post.thread_id = $1 + ORDER BY postdate + LIMIT $2 OFFSET $3 + `, + thread.ID, + postsPerPage, + (page-1)*postsPerPage, + ) + c.Perf.EndBlock() + if err != nil { + panic(err) + } + defer itPosts.Close() + + var posts []templates.Post + for _, irow := range itPosts.ToSlice() { + row := irow.(*postsQueryResult) + posts = append(posts, templates.PostToTemplateWithContent(&row.Post, row.Author, row.Content)) + } + + baseData := getBaseData(c) + + var res ResponseData + err = res.WriteTemplate("forum_thread.html", forumThreadData{ + BaseData: baseData, + Thread: templates.ThreadToTemplate(&thread), + Posts: posts, + }, c.Perf) + if err != nil { + panic(err) + } + + return res +} + func fetchCatIdFromSlugs(ctx context.Context, conn *pgxpool.Pool, catSlugs []string, projectId int) int { if len(catSlugs) == 1 { var err error diff --git a/src/website/pagination.go b/src/website/pagination.go new file mode 100644 index 00000000..7552244b --- /dev/null +++ b/src/website/pagination.go @@ -0,0 +1,33 @@ +package website + +import ( + "math" + "strconv" +) + +func getPageInfo( + pageParam string, + totalItems int, + itemsPerPage int, +) ( + page int, + totalPages int, + ok bool, +) { + totalPages = int(math.Ceil(float64(totalItems) / float64(itemsPerPage))) + ok = true + + page = 1 + if pageParam != "" { + if pageParsed, err := strconv.Atoi(pageParam); err == nil { + page = pageParsed + } else { + return 0, 0, false + } + } + if page < 1 || totalPages < page { + return 0, 0, false + } + + return +} diff --git a/src/website/routes.go b/src/website/routes.go index f13b6b69..5c3650bf 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -73,8 +73,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt }) mainRoutes.GET(`^/feed(/(?P.+)?)?$`, Feed) - mainRoutes.GET(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`, ForumCategory) + mainRoutes.GET(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`, ForumThread) // mainRoutes.GET(`^/(?Pforums(/cat)*)/t/(?P\d+)/p/(?P\d+)$`, ForumPost) + mainRoutes.GET(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`, ForumCategory) mainRoutes.GET("^/assets/project.css$", ProjectCSS) @@ -145,12 +146,12 @@ func ProjectCSS(c *RequestContext) ResponseData { templateData := struct { templates.BaseData - Color string - PostListBgColor string + Color string + PostBgColor string }{ - BaseData: baseData, - Color: color, - PostListBgColor: bgColor.HTML(), + BaseData: baseData, + Color: color, + PostBgColor: bgColor.HTML(), } var res ResponseData