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 0000000..8077ccf
--- /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 0000000..0d97c27
--- /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 1867752..fea2707 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 0000000..034ad95
--- /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 aabf49c..8d6f656 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 ce8e6eb..9725294 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 8397b2c..b8c6e91 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 0000000..7552244
--- /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 f13b6b6..5c3650b 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