Get forum threads mostly implemented
Still several TODOs in the handler and templates
This commit is contained in:
parent
b217cd5592
commit
71a46ba5a1
|
@ -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")
|
||||
}
|
|
@ -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")
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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">✖</a>
|
||||
<a class="edit action button" href="{{ .Url }}/edit" title="Edit">✎</a>
|
||||
{{ 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">↪</a>
|
||||
<a class="quote action button" href="{{ .Url }}/quote" title="Quote">❝</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 }}
|
|
@ -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 */
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -73,8 +73,9 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
|||
})
|
||||
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(/[^\d]+?)*)(/(?P<page>\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
|
||||
|
|
Loading…
Reference in New Issue