Get forum threads mostly implemented

Still several TODOs in the handler and templates
This commit is contained in:
Ben Visness 2021-05-03 20:59:45 -05:00
parent b217cd5592
commit 71a46ba5a1
9 changed files with 402 additions and 15 deletions

View File

@ -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")
}

View File

@ -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")
}

View File

@ -1,25 +1,37 @@
package templates package templates
import ( import (
"html/template"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "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{ return Post{
Author: UserToTemplate(&author), ID: p.ID,
Url: "nope", // TODO
Preview: p.Preview, Preview: p.Preview,
ReadOnly: p.ReadOnly, ReadOnly: p.ReadOnly,
Author: authorUser,
// No content. Do it yourself if you care. // No content. Do it yourself if you care.
PostDate: p.PostDate,
IP: p.IP.String(), 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 := PostToTemplate(p, author)
post.Content = content post.Content = template.HTML(content)
return post 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 { func UserToTemplate(u *models.User) User {
// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function. // 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{ return User{
ID: u.ID,
Username: u.Username, Username: u.Username,
Email: u.Email, Email: u.Email,
IsSuperuser: u.IsSuperuser, IsSuperuser: u.IsSuperuser,

View File

@ -0,0 +1,112 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="content-block">
{{ range .Posts }}
<div class="post background-even pa3 bbcode"> {{/* TODO: Dynamically switch between bbcode and markdown */}}
<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">
<!-- Mobile avatar -->
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
<div class="w-100-l pl3 pl0-l flex flex-column items-center-l">
<div>
<a class="username" href="{{ .Author.ProfileUrl }}" target="_blank">{{ .Author.Username }}</a> {{/* TODO: Text scale stuff? Seems unnecessary. */}}
<!-- Mobile badges -->
<div class="di dn-l 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 }}
<!-- Large avatar -->
<div class="dn db-l w-60 pv2">
<div class="aspect-ratio--1x1 contain bg-center" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
</div>
{{/* TODO: Aggregate user data
<div class="c--dim f7">
{{ 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 %}
</div> */}}
<!-- Large badges -->
<div class="dn db-l pv2">
{{ if .Author.IsStaff }}
<div class="badge staff"></div>
{{ end }}
</div>
<div class="i c--dim f7">
{{ if .Author.Blurb }}
{{ .Author.Blurb }} {{/* TODO: Linebreaks? */}}
{{ else if .Author.Bio }}
{{ .Author.Bio }}
{{ end }}
</div>
</div>
{{ else }}
<div class="username">Deleted member</div>
<div class="avatar" style="background-image:url('{{ .Author.AvatarUrl }}');"></div>
{{ end }}
</div>
<div class="fl w-100 w-75-l pv3 pa3-l">
<div class="w-100 flex-l flex-row-reverse-l">
<div class="inline-flex flex-row-reverse pl3-l pb3 items-center">
<span class="postid">
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
</span>
{{ if $.User }}
<div class="flex pr3">
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
<a class="delete action button" href="{{ .Url }}/delete" title="Delete">&#10006;</a>&nbsp;
<a class="edit action button" href="{{ .Url }}/edit" title="Edit">&#9998;</a>&nbsp;
{{ end }}
{{ if or (not $.Thread.Locked) $.User.IsStaff }}
{{ if $.Thread.Locked }}
WARNING: locked thread - use power responsibly!
{{ end }}
<a class="reply action button" href="{{ .Url }}/reply" title="Reply">&hookrightarrow;</a>&nbsp;
<a class="quote action button" href="{{ .Url }}/quote" title="Quote">&#10077;</a>
{{ end }}
</div>
{{ end }}
</div>
<div class="w-100 pb3">
<div class="b" role="heading" aria-level="2">{{ $.Thread.Title }}</div>
<span>{{ relativedate .PostDate }} ago</span>
{{ if .Editor }}
<span class="pl3">
Edited by
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
{{ if and $.User.IsStaff .EditIP }}<span class="ip">[{{ .EditIP }}]</span>{{ end }}
on <span class="editdate">{{ .EditDate }}</span>
{{ with .EditReason }}
Reason: {{ . }}
{{ end }}
</span>
{{ end }}
{{ if $.User }}
{{ if $.User.IsStaff }}
<span>[{{ .IP }}]</span>
{{ end }}
{{ end }}
</div>
</div>
<div class="contents overflow-x-auto">
{{ .Content }}
</div>
{{/* {% if post.author.signature|length %}
<div class="signature"><hr />
{{ post.author.signature|bbdecode|safe }}
</div>
{% endif %} */}}
</div>
<div class="cb"></div>
</div>
{{ end }}
</div>
{{ end }}

View File

@ -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) { .post-list-bg-odd:nth-of-type(odd) {
background-color: {{ .PostListBgColor }}; background-color: {{ .PostBgColor }};
} }
/* /*
@ -96,7 +96,7 @@ all of this CSS.
{% endif %} */ {% endif %} */
:root { :root {
--background-even-background: {{ eq .Theme "dark" | ternary (lightness 0.15 $c) (lightness 0.95 $c) | color2css }}; --background-even-background: {{ .PostBgColor }};
} }
/* Assets */ /* Assets */

View File

@ -1,6 +1,9 @@
package templates package templates
import "time" import (
"html/template"
"time"
)
type BaseData struct { type BaseData struct {
Title string Title string
@ -23,11 +26,20 @@ type Thread struct {
} }
type Post struct { type Post struct {
Author User ID int
Url string
Preview string Preview string
ReadOnly bool ReadOnly bool
Content string Author *User
Content template.HTML
PostDate time.Time
Editor *User
EditDate time.Time
EditIP string
EditReason string
IP string IP string
} }
@ -47,6 +59,7 @@ type Project struct {
} }
type User struct { type User struct {
ID int
Username string Username string
Email string Email string
IsSuperuser bool IsSuperuser bool
@ -54,6 +67,7 @@ type User struct {
Name string Name string
Blurb string Blurb string
Bio string
Signature string Signature string
AvatarUrl string AvatarUrl string
ProfileUrl string ProfileUrl string

View File

@ -2,6 +2,7 @@ package website
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"math" "math"
"net/http" "net/http"
@ -284,6 +285,100 @@ func ForumCategory(c *RequestContext) ResponseData {
return res 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 { func fetchCatIdFromSlugs(ctx context.Context, conn *pgxpool.Pool, catSlugs []string, projectId int) int {
if len(catSlugs) == 1 { if len(catSlugs) == 1 {
var err error var err error

33
src/website/pagination.go Normal file
View File

@ -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
}

View File

@ -73,8 +73,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
}) })
mainRoutes.GET(`^/feed(/(?P<page>.+)?)?$`, Feed) mainRoutes.GET(`^/feed(/(?P<page>.+)?)?$`, Feed)
mainRoutes.GET(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\d+))?$`, ForumCategory) mainRoutes.GET(`^/(?P<cats>forums(/[^\d]+?)*)/t/(?P<threadid>\d+)(/(?P<page>\d+))?$`, ForumThread)
// mainRoutes.GET(`^/(?P<cats>forums(/cat)*)/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`, ForumPost) // mainRoutes.GET(`^/(?P<cats>forums(/cat)*)/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`, ForumPost)
mainRoutes.GET(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\d+))?$`, ForumCategory)
mainRoutes.GET("^/assets/project.css$", ProjectCSS) mainRoutes.GET("^/assets/project.css$", ProjectCSS)
@ -145,12 +146,12 @@ func ProjectCSS(c *RequestContext) ResponseData {
templateData := struct { templateData := struct {
templates.BaseData templates.BaseData
Color string Color string
PostListBgColor string PostBgColor string
}{ }{
BaseData: baseData, BaseData: baseData,
Color: color, Color: color,
PostListBgColor: bgColor.HTML(), PostBgColor: bgColor.HTML(),
} }
var res ResponseData var res ResponseData