Merge branch 'master' of gitssh.handmade.network:hmn/hmn
This commit is contained in:
commit
4e946cd476
|
@ -4,6 +4,7 @@ import (
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -37,10 +38,15 @@ func Url(path string, query []Q) string {
|
||||||
return ProjectUrl(path, query, "")
|
return ProjectUrl(path, query, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectUrl(path string, query []Q, subdomain string) string {
|
func ProjectUrl(path string, query []Q, slug string) string {
|
||||||
|
subdomain := slug
|
||||||
|
if slug == models.HMNProjectSlug {
|
||||||
|
subdomain = ""
|
||||||
|
}
|
||||||
|
|
||||||
host := baseUrlParsed.Host
|
host := baseUrlParsed.Host
|
||||||
if len(subdomain) > 0 {
|
if len(subdomain) > 0 {
|
||||||
host = subdomain + "." + host
|
host = slug + "." + host
|
||||||
}
|
}
|
||||||
|
|
||||||
url := url.URL{
|
url := url.URL{
|
||||||
|
|
|
@ -1,73 +1,74 @@
|
||||||
package hmnurl
|
package hmnurl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
|
||||||
"regexp"
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
|
||||||
var RegexHomepage *regexp.Regexp = regexp.MustCompile("^/$")
|
var RegexHomepage = regexp.MustCompile("^/$")
|
||||||
|
|
||||||
func BuildHomepage() string {
|
func BuildHomepage() string {
|
||||||
return Url("/", nil)
|
return Url("/", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexLogin *regexp.Regexp = regexp.MustCompile("^/login$")
|
var RegexLogin = regexp.MustCompile("^/login$")
|
||||||
|
|
||||||
func BuildLogin() string {
|
func BuildLogin() string {
|
||||||
return Url("/login", nil)
|
return Url("/login", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexLogout *regexp.Regexp = regexp.MustCompile("^/logout$")
|
var RegexLogout = regexp.MustCompile("^/logout$")
|
||||||
|
|
||||||
func BuildLogout() string {
|
func BuildLogout() string {
|
||||||
return Url("/logout", nil)
|
return Url("/logout", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexManifesto *regexp.Regexp = regexp.MustCompile("^/manifesto$")
|
var RegexManifesto = regexp.MustCompile("^/manifesto$")
|
||||||
|
|
||||||
func BuildManifesto() string {
|
func BuildManifesto() string {
|
||||||
return Url("/manifesto", nil)
|
return Url("/manifesto", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexAbout *regexp.Regexp = regexp.MustCompile("^/about$")
|
var RegexAbout = regexp.MustCompile("^/about$")
|
||||||
|
|
||||||
func BuildAbout() string {
|
func BuildAbout() string {
|
||||||
return Url("/about", nil)
|
return Url("/about", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexCodeOfConduct *regexp.Regexp = regexp.MustCompile("^/code-of-conduct$")
|
var RegexCodeOfConduct = regexp.MustCompile("^/code-of-conduct$")
|
||||||
|
|
||||||
func BuildCodeOfConduct() string {
|
func BuildCodeOfConduct() string {
|
||||||
return Url("/code-of-conduct", nil)
|
return Url("/code-of-conduct", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexCommunicationGuidelines *regexp.Regexp = regexp.MustCompile("^/communication-guidelines$")
|
var RegexCommunicationGuidelines = regexp.MustCompile("^/communication-guidelines$")
|
||||||
|
|
||||||
func BuildCommunicationGuidelines() string {
|
func BuildCommunicationGuidelines() string {
|
||||||
return Url("/communication-guidelines", nil)
|
return Url("/communication-guidelines", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexContactPage *regexp.Regexp = regexp.MustCompile("^/contact$")
|
var RegexContactPage = regexp.MustCompile("^/contact$")
|
||||||
|
|
||||||
func BuildContactPage() string {
|
func BuildContactPage() string {
|
||||||
return Url("/contact", nil)
|
return Url("/contact", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexMonthlyUpdatePolicy *regexp.Regexp = regexp.MustCompile("^/monthly-update-policy$")
|
var RegexMonthlyUpdatePolicy = regexp.MustCompile("^/monthly-update-policy$")
|
||||||
|
|
||||||
func BuildMonthlyUpdatePolicy() string {
|
func BuildMonthlyUpdatePolicy() string {
|
||||||
return Url("/monthly-update-policy", nil)
|
return Url("/monthly-update-policy", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectSubmissionGuidelines *regexp.Regexp = regexp.MustCompile("^/project-guidelines$")
|
var RegexProjectSubmissionGuidelines = regexp.MustCompile("^/project-guidelines$")
|
||||||
|
|
||||||
func BuildProjectSubmissionGuidelines() string {
|
func BuildProjectSubmissionGuidelines() string {
|
||||||
return Url("/project-guidelines", nil)
|
return Url("/project-guidelines", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexFeed *regexp.Regexp = regexp.MustCompile(`^/feed(/(?P<page>.+)?)?$`)
|
var RegexFeed = regexp.MustCompile(`^/feed(/(?P<page>.+)?)?$`)
|
||||||
|
|
||||||
func BuildFeed() string {
|
func BuildFeed() string {
|
||||||
return Url("/feed", nil)
|
return Url("/feed", nil)
|
||||||
|
@ -83,13 +84,9 @@ func BuildFeedWithPage(page int) string {
|
||||||
return Url("/feed/"+strconv.Itoa(page), nil)
|
return Url("/feed/"+strconv.Itoa(page), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumThread *regexp.Regexp = regexp.MustCompile(`^/(?P<cats>forums(/[^\d]+?)*)/t/(?P<threadid>\d+)(/(?P<page>\d+))?$`)
|
var RegexForumThread = regexp.MustCompile(`^/(?P<cats>forums(/[^\d]+?)*)/t/(?P<threadid>\d+)(/(?P<page>\d+))?$`)
|
||||||
|
|
||||||
func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string {
|
func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string {
|
||||||
if projectSlug == "hmn" {
|
|
||||||
projectSlug = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
|
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
|
||||||
}
|
}
|
||||||
|
@ -117,13 +114,9 @@ func BuildForumThread(projectSlug string, subforums []string, threadId int, page
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumCategory *regexp.Regexp = regexp.MustCompile(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\d+))?$`)
|
var RegexForumCategory = regexp.MustCompile(`^/(?P<cats>forums(/[^\d]+?)*)(/(?P<page>\d+))?$`)
|
||||||
|
|
||||||
func BuildForumCategory(projectSlug string, subforums []string, page int) string {
|
func BuildForumCategory(projectSlug string, subforums []string, page int) string {
|
||||||
if projectSlug == "hmn" {
|
|
||||||
projectSlug = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
|
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
|
||||||
}
|
}
|
||||||
|
@ -149,13 +142,9 @@ func BuildForumCategory(projectSlug string, subforums []string, page int) string
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
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 {
|
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
|
||||||
if projectSlug == "hmn" {
|
|
||||||
projectSlug = ""
|
|
||||||
}
|
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
builder.WriteString("/forums")
|
builder.WriteString("/forums")
|
||||||
for _, subforum := range subforums {
|
for _, subforum := range subforums {
|
||||||
|
@ -177,13 +166,38 @@ func BuildForumPost(projectSlug string, subforums []string, threadId int, postId
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectCSS *regexp.Regexp = regexp.MustCompile("^/assets/project.css$")
|
var RegexForumPostDelete = regexp.MustCompile(``) // TODO
|
||||||
|
|
||||||
func BuildProjectCSS(color string) string {
|
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
|
||||||
return Url("/assets/project.css", []Q{Q{"color", color}})
|
return BuildForumPost(projectSlug, subforums, threadId, postId) + "/delete"
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexPublic *regexp.Regexp = regexp.MustCompile("^/public/.+$")
|
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.MustCompile("^/public/.+$")
|
||||||
|
|
||||||
func BuildPublic(filepath string) string {
|
func BuildPublic(filepath string) string {
|
||||||
filepath = strings.Trim(filepath, "/")
|
filepath = strings.Trim(filepath, "/")
|
||||||
|
@ -204,4 +218,4 @@ func BuildPublic(filepath string) string {
|
||||||
return Url(builder.String(), nil)
|
return Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexCatchAll *regexp.Regexp = regexp.MustCompile("")
|
var RegexCatchAll = regexp.MustCompile("")
|
||||||
|
|
|
@ -0,0 +1,47 @@
|
||||||
|
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(RemoveProjectNulls{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type RemoveProjectNulls struct{}
|
||||||
|
|
||||||
|
func (m RemoveProjectNulls) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 5, 6, 3, 13, 28, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m RemoveProjectNulls) Name() string {
|
||||||
|
return "RemoveProjectNulls"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m RemoveProjectNulls) Description() string {
|
||||||
|
return "Make project fields non-nullable"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m RemoveProjectNulls) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
ALTER slug SET NOT NULL,
|
||||||
|
ALTER name SET NOT NULL,
|
||||||
|
ALTER blurb SET NOT NULL,
|
||||||
|
ALTER description SET NOT NULL;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to make project fields non-null")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m RemoveProjectNulls) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
panic("Implement me")
|
||||||
|
}
|
|
@ -34,13 +34,66 @@ type Category struct {
|
||||||
Depth int `db:"depth"` // TODO: What is this?
|
Depth int `db:"depth"` // TODO: What is this?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CategoryTree map[int]*CategoryTreeNode
|
||||||
|
|
||||||
|
type CategoryTreeNode struct {
|
||||||
|
Category
|
||||||
|
Parent *CategoryTreeNode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (node *CategoryTreeNode) GetLineage() []*Category {
|
||||||
|
current := node
|
||||||
|
length := 0
|
||||||
|
for current != nil {
|
||||||
|
current = current.Parent
|
||||||
|
length += 1
|
||||||
|
}
|
||||||
|
result := make([]*Category, length)
|
||||||
|
current = node
|
||||||
|
for i := length - 1; i >= 0; i -= 1 {
|
||||||
|
result[i] = ¤t.Category
|
||||||
|
current = current.Parent
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetFullCategoryTree(ctx context.Context, conn *pgxpool.Pool) CategoryTree {
|
||||||
|
type categoryRow struct {
|
||||||
|
Cat Category `db:"cat"`
|
||||||
|
}
|
||||||
|
rows, err := db.Query(ctx, conn, categoryRow{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_category as cat
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "Failed to fetch category tree"))
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsSlice := rows.ToSlice()
|
||||||
|
catTreeMap := make(map[int]*CategoryTreeNode, len(rowsSlice))
|
||||||
|
for _, row := range rowsSlice {
|
||||||
|
cat := row.(*categoryRow).Cat
|
||||||
|
catTreeMap[cat.ID] = &CategoryTreeNode{Category: cat}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, node := range catTreeMap {
|
||||||
|
if node.ParentID != nil {
|
||||||
|
node.Parent = catTreeMap[*node.ParentID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return catTreeMap
|
||||||
|
}
|
||||||
|
|
||||||
type CategoryLineageBuilder struct {
|
type CategoryLineageBuilder struct {
|
||||||
Tree map[int]*CategoryTreeNode
|
Tree CategoryTree
|
||||||
CategoryCache map[int][]*Category
|
CategoryCache map[int][]*Category
|
||||||
SlugCache map[int][]string
|
SlugCache map[int][]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakeCategoryLineageBuilder(fullCategoryTree map[int]*CategoryTreeNode) *CategoryLineageBuilder {
|
func MakeCategoryLineageBuilder(fullCategoryTree CategoryTree) *CategoryLineageBuilder {
|
||||||
return &CategoryLineageBuilder{
|
return &CategoryLineageBuilder{
|
||||||
Tree: fullCategoryTree,
|
Tree: fullCategoryTree,
|
||||||
CategoryCache: make(map[int][]*Category),
|
CategoryCache: make(map[int][]*Category),
|
||||||
|
@ -72,93 +125,3 @@ func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string {
|
||||||
}
|
}
|
||||||
return cl.SlugCache[catId]
|
return cl.SlugCache[catId]
|
||||||
}
|
}
|
||||||
|
|
||||||
type CategoryTreeNode struct {
|
|
||||||
Category
|
|
||||||
Parent *CategoryTreeNode
|
|
||||||
}
|
|
||||||
|
|
||||||
func (node *CategoryTreeNode) GetLineage() []*Category {
|
|
||||||
current := node
|
|
||||||
length := 0
|
|
||||||
for current != nil {
|
|
||||||
current = current.Parent
|
|
||||||
length += 1
|
|
||||||
}
|
|
||||||
result := make([]*Category, length)
|
|
||||||
current = node
|
|
||||||
for i := length - 1; i >= 0; i -= 1 {
|
|
||||||
result[i] = ¤t.Category
|
|
||||||
current = current.Parent
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
func GetFullCategoryTree(ctx context.Context, conn *pgxpool.Pool) map[int]*CategoryTreeNode {
|
|
||||||
type categoryRow struct {
|
|
||||||
Cat Category `db:"cat"`
|
|
||||||
}
|
|
||||||
rows, err := db.Query(ctx, conn, categoryRow{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_category as cat
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
panic(oops.New(err, "Failed to fetch category tree"))
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsSlice := rows.ToSlice()
|
|
||||||
catTreeMap := make(map[int]*CategoryTreeNode, len(rowsSlice))
|
|
||||||
for _, row := range rowsSlice {
|
|
||||||
cat := row.(*categoryRow).Cat
|
|
||||||
catTreeMap[cat.ID] = &CategoryTreeNode{Category: cat}
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, node := range catTreeMap {
|
|
||||||
if node.ParentID != nil {
|
|
||||||
node.Parent = catTreeMap[*node.ParentID]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return catTreeMap
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Gets the category and its parent categories, starting from the root and working toward the
|
|
||||||
category itself. Useful for breadcrumbs and the like.
|
|
||||||
*/
|
|
||||||
func (c *Category) GetHierarchy(ctx context.Context, conn *pgxpool.Pool) []Category {
|
|
||||||
// TODO: Make this work for a whole set of categories at once. Should be doable.
|
|
||||||
type breadcrumbRow struct {
|
|
||||||
Cat Category `db:"cats"`
|
|
||||||
}
|
|
||||||
rows, err := db.Query(ctx, conn, breadcrumbRow{},
|
|
||||||
`
|
|
||||||
WITH RECURSIVE cats AS (
|
|
||||||
SELECT *
|
|
||||||
FROM handmade_category AS cat
|
|
||||||
WHERE cat.id = $1
|
|
||||||
UNION ALL
|
|
||||||
SELECT parentcat.*
|
|
||||||
FROM
|
|
||||||
handmade_category AS parentcat
|
|
||||||
JOIN cats ON cats.parent_id = parentcat.id
|
|
||||||
)
|
|
||||||
SELECT $columns FROM cats;
|
|
||||||
`,
|
|
||||||
c.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
rowsSlice := rows.ToSlice()
|
|
||||||
var result []Category
|
|
||||||
for i := len(rowsSlice) - 1; i >= 0; i-- {
|
|
||||||
row := rowsSlice[i].(*breadcrumbRow)
|
|
||||||
result = append(result, row.Cat)
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
|
@ -5,7 +5,10 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const HMNProjectID = 1
|
const (
|
||||||
|
HMNProjectID = 1
|
||||||
|
HMNProjectSlug = "hmn"
|
||||||
|
)
|
||||||
|
|
||||||
var ProjectType = reflect.TypeOf(Project{})
|
var ProjectType = reflect.TypeOf(Project{})
|
||||||
|
|
||||||
|
@ -24,10 +27,10 @@ const (
|
||||||
type Project struct {
|
type Project struct {
|
||||||
ID int `db:"id"`
|
ID int `db:"id"`
|
||||||
|
|
||||||
Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL
|
Slug string `db:"slug"`
|
||||||
Name *string `db:"name"`
|
Name string `db:"name"`
|
||||||
Blurb *string `db:"blurb"`
|
Blurb string `db:"blurb"`
|
||||||
Description *string `db:"description"`
|
Description string `db:"description"`
|
||||||
|
|
||||||
Lifecycle ProjectLifecycle `db:"lifecycle"`
|
Lifecycle ProjectLifecycle `db:"lifecycle"`
|
||||||
|
|
||||||
|
@ -46,5 +49,5 @@ func (p *Project) Subdomain() string {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return *p.Slug
|
return p.Slug
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package templates
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"net"
|
||||||
|
|
||||||
"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"
|
||||||
|
@ -15,30 +16,44 @@ func PostToTemplate(p *models.Post, author *models.User) Post {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Post{
|
return Post{
|
||||||
ID: p.ID,
|
ID: p.ID,
|
||||||
Url: "nope", // TODO
|
|
||||||
|
// Urls not set here. See AddUrls.
|
||||||
|
|
||||||
Preview: p.Preview,
|
Preview: p.Preview,
|
||||||
ReadOnly: p.ReadOnly,
|
ReadOnly: p.ReadOnly,
|
||||||
|
|
||||||
Author: authorUser,
|
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,
|
PostDate: p.PostDate,
|
||||||
|
|
||||||
IP: p.IP.String(),
|
IP: p.IP.String(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func PostToTemplateWithContent(p *models.Post, author *models.User, content string) Post {
|
func (p *Post) AddContentVersion(ver models.PostVersion, editor *models.User) {
|
||||||
post := PostToTemplate(p, author)
|
p.Content = template.HTML(ver.TextParsed)
|
||||||
post.Content = template.HTML(content)
|
|
||||||
|
|
||||||
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 {
|
func ProjectToTemplate(p *models.Project) Project {
|
||||||
return Project{
|
return Project{
|
||||||
Name: maybeString(p.Name),
|
Name: p.Name,
|
||||||
Subdomain: p.Subdomain(),
|
Subdomain: p.Subdomain(),
|
||||||
Color1: p.Color1,
|
Color1: p.Color1,
|
||||||
Color2: p.Color2,
|
Color2: p.Color2,
|
||||||
|
@ -103,3 +118,11 @@ func maybeString(s *string) string {
|
||||||
}
|
}
|
||||||
return *s
|
return *s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func maybeIp(ip *net.IPNet) string {
|
||||||
|
if ip == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip.String()
|
||||||
|
}
|
||||||
|
|
|
@ -2,6 +2,10 @@
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="content-block">
|
<div class="content-block">
|
||||||
|
<div class="optionbar">
|
||||||
|
<a class="button" href="{{ .CategoryUrl }}">← Back to index</a>
|
||||||
|
{{ template "pagination.html" .Pagination }}
|
||||||
|
</div>
|
||||||
{{ range .Posts }}
|
{{ range .Posts }}
|
||||||
<div class="post background-even pa3 bbcode"> {{/* TODO: Dynamically switch between bbcode and markdown */}}
|
<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">
|
<div class="fl w-100 w-25-l pt3 pa3-l flex tc-l">
|
||||||
|
@ -62,28 +66,28 @@
|
||||||
{{ if $.User }}
|
{{ if $.User }}
|
||||||
<div class="flex pr3">
|
<div class="flex pr3">
|
||||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||||
<a class="delete action button" href="{{ .Url }}/delete" title="Delete">✖</a>
|
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||||
<a class="edit action button" href="{{ .Url }}/edit" title="Edit">✎</a>
|
<a class="edit action button" href="{{ .EditUrl }}" title="Edit">✎</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if or (not $.Thread.Locked) $.User.IsStaff }}
|
{{ if or (not $.Thread.Locked) $.User.IsStaff }}
|
||||||
{{ if $.Thread.Locked }}
|
{{ if $.Thread.Locked }}
|
||||||
WARNING: locked thread - use power responsibly!
|
WARNING: locked thread - use power responsibly!
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<a class="reply action button" href="{{ .Url }}/reply" title="Reply">↪</a>
|
<a class="reply action button" href="{{ .ReplyUrl }}" title="Reply">↪</a>
|
||||||
<a class="quote action button" href="{{ .Url }}/quote" title="Quote">❝</a>
|
<a class="quote action button" href="{{ .QuoteUrl }}" title="Quote">❝</a>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="w-100 pb3">
|
<div class="w-100 pb3">
|
||||||
<div class="b" role="heading" aria-level="2">{{ $.Thread.Title }}</div>
|
<div class="b" role="heading" aria-level="2">{{ $.Thread.Title }}</div>
|
||||||
<span>{{ relativedate .PostDate }} ago</span>
|
{{ timehtml (relativedate .PostDate) .PostDate }}
|
||||||
{{ if .Editor }}
|
{{ if .Editor }}
|
||||||
<span class="pl3">
|
<span class="pl3">
|
||||||
Edited by
|
Edited by
|
||||||
<a class="name" href="{{ .Editor.ProfileUrl }}" target="_blank">{{ coalesce .Editor.Name .Editor.Username }}</a>
|
<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 }}
|
{{ if and $.User.IsStaff .EditIP }}<span class="ip">[{{ .EditIP }}]</span>{{ end }}
|
||||||
on <span class="editdate">{{ .EditDate }}</span>
|
on {{ timehtml (absolutedate .EditDate) .EditDate }}
|
||||||
{{ with .EditReason }}
|
{{ with .EditReason }}
|
||||||
Reason: {{ . }}
|
Reason: {{ . }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
@ -108,5 +112,20 @@
|
||||||
<div class="cb"></div>
|
<div class="cb"></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
<div class="optionbar bottom">
|
||||||
|
<div class="options order-1">
|
||||||
|
<a class="button" href="{{ .CategoryUrl }}">← Back to index</a>
|
||||||
|
{{ if .Thread.Locked }}
|
||||||
|
<span>Thread is locked.</span>
|
||||||
|
{{ else if .User }}
|
||||||
|
<a class="button" href="{{ .ReplyUrl }}">⤷ Reply to Thread</a>
|
||||||
|
{{ else }}
|
||||||
|
<span><a href="{% url 'member_login' subdomain=None %}">Log in</a> to reply</span>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="options order-0 order-last-ns">
|
||||||
|
{{ template "pagination.html" .Pagination }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -87,5 +87,10 @@
|
||||||
loginPopup.classList.toggle("open");
|
loginPopup.classList.toggle("open");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for (const time of document.querySelectorAll('time')) {
|
||||||
|
const d = new Date(Date.parse(time.dateTime));
|
||||||
|
time.title = d.toLocaleString();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -15,7 +15,7 @@ It should be called with PostListItem.
|
||||||
</div>
|
</div>
|
||||||
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> — <span class="datetime">{{ relativedate .Date }}</span>
|
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> — {{ timehtml (relativedate .Date) .Date }}
|
||||||
</div>
|
</div>
|
||||||
{{ with .Content }}
|
{{ with .Content }}
|
||||||
<div class="mt2">
|
<div class="mt2">
|
||||||
|
|
|
@ -15,7 +15,7 @@ It should be called with ThreadListItem.
|
||||||
</div>
|
</div>
|
||||||
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
<div class="title nowrap truncate"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<a class="user" href="{{ .FirstUser.ProfileUrl }}">{{ .FirstUser.Name }}</a> — <span class="datetime">{{ relativedate .FirstDate }}</span>
|
<a class="user" href="{{ .FirstUser.ProfileUrl }}">{{ .FirstUser.Name }}</a> — {{ timehtml (relativedate .FirstDate) .FirstDate }}
|
||||||
</div>
|
</div>
|
||||||
{{ with .Content }}
|
{{ with .Content }}
|
||||||
<div class="mt2">
|
<div class="mt2">
|
||||||
|
@ -26,7 +26,7 @@ It should be called with ThreadListItem.
|
||||||
<div class="latestpost dn flex-ns flex-shrink-0 items-center ml2">
|
<div class="latestpost dn flex-ns flex-shrink-0 items-center ml2">
|
||||||
<img class="avatar-icon mr2" src="{{ .LastUser.AvatarUrl }}">
|
<img class="avatar-icon mr2" src="{{ .LastUser.AvatarUrl }}">
|
||||||
<div>
|
<div>
|
||||||
<div>Last post <span class="datetime">{{ relativedate .LastDate }}</span></div>
|
<div>Last post {{ timehtml (relativedate .LastDate) .LastDate }}</div>
|
||||||
<a class="user" href="{{ .LastUser.ProfileUrl }}">{{ .LastUser.Name }}</a>
|
<a class="user" href="{{ .LastUser.ProfileUrl }}">{{ .LastUser.Name }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -271,7 +271,7 @@
|
||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<div class="title mb1"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
<div class="title mb1"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
||||||
<div class="details">
|
<div class="details">
|
||||||
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> — <span class="datetime">{{ relativedate .Date }}</span>
|
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> — {{ timehtml (relativedate .Date) .Date }}
|
||||||
</div>
|
</div>
|
||||||
<div class="overflow-hidden mh-5 mt2 relative">
|
<div class="overflow-hidden mh-5 mt2 relative">
|
||||||
<div>
|
<div>
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ if .Title }}
|
{{ if .Title }}
|
||||||
<title>{{ .Title }} | Handmade Network</title>
|
<title>{{ .Title }} | Handmade Network</title> {{/* TODO: Some parts of the site replace "Handmade Network" with other things like "4coder Forums". */}}
|
||||||
{{ else }}
|
{{ else }}
|
||||||
<title>Handmade Network</title>
|
<title>Handmade Network</title>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -67,6 +67,9 @@ func names(ts []*template.Template) []string {
|
||||||
}
|
}
|
||||||
|
|
||||||
var HMNTemplateFuncs = template.FuncMap{
|
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 {
|
"alpha": func(alpha float64, color noire.Color) noire.Color {
|
||||||
color.Alpha = alpha
|
color.Alpha = alpha
|
||||||
return color
|
return color
|
||||||
|
@ -157,6 +160,10 @@ var HMNTemplateFuncs = template.FuncMap{
|
||||||
"staticthemenobust": func(theme string, filepath string) string {
|
"staticthemenobust": func(theme string, filepath string) string {
|
||||||
return hmnurl.StaticThemeUrl(filepath, theme, nil)
|
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(`<time datetime="%s">%s</time>`, iso, formatted))
|
||||||
|
},
|
||||||
"url": func(url string) string {
|
"url": func(url string) string {
|
||||||
return hmnurl.Url(url, nil)
|
return hmnurl.Url(url, nil)
|
||||||
},
|
},
|
||||||
|
|
|
@ -26,8 +26,13 @@ type Thread struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type Post struct {
|
type Post struct {
|
||||||
ID int
|
ID int
|
||||||
Url string
|
|
||||||
|
Url string
|
||||||
|
DeleteUrl string
|
||||||
|
EditUrl string
|
||||||
|
ReplyUrl string
|
||||||
|
QuoteUrl string
|
||||||
|
|
||||||
Preview string
|
Preview string
|
||||||
ReadOnly bool
|
ReadOnly bool
|
||||||
|
|
|
@ -124,13 +124,13 @@ func Feed(c *RequestContext) ResponseData {
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
categoryUrlCache := make(map[int]string)
|
categoryUrlCache := make(map[int]string)
|
||||||
getCategoryUrl := func(subdomain string, cat *models.Category) string {
|
getCategoryUrl := func(projectSlug string, cat *models.Category) string {
|
||||||
_, ok := categoryUrlCache[cat.ID]
|
_, ok := categoryUrlCache[cat.ID]
|
||||||
if !ok {
|
if !ok {
|
||||||
lineageNames := lineageBuilder.GetLineageSlugs(cat.ID)
|
lineageNames := lineageBuilder.GetLineageSlugs(cat.ID)
|
||||||
switch cat.Kind {
|
switch cat.Kind {
|
||||||
case models.CatKindForum:
|
case models.CatKindForum:
|
||||||
categoryUrlCache[cat.ID] = hmnurl.BuildForumCategory(subdomain, lineageNames[1:], 1)
|
categoryUrlCache[cat.ID] = hmnurl.BuildForumCategory(projectSlug, lineageNames[1:], 1)
|
||||||
// TODO(asaf): Add more kinds!!!
|
// TODO(asaf): Add more kinds!!!
|
||||||
default:
|
default:
|
||||||
categoryUrlCache[cat.ID] = ""
|
categoryUrlCache[cat.ID] = ""
|
||||||
|
@ -153,8 +153,8 @@ func Feed(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID)))
|
breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID)))
|
||||||
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
|
breadcrumbs = append(breadcrumbs, templates.Breadcrumb{
|
||||||
Name: *postResult.Proj.Name,
|
Name: postResult.Proj.Name,
|
||||||
Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Subdomain()),
|
Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Slug),
|
||||||
})
|
})
|
||||||
if postResult.Post.CategoryKind == models.CatKindLibraryResource {
|
if postResult.Post.CategoryKind == models.CatKindLibraryResource {
|
||||||
// TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it
|
// TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it
|
||||||
|
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
"github.com/jackc/pgx/v4/pgxpool"
|
"github.com/jackc/pgx/v4/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -261,11 +262,11 @@ func ForumCategory(c *RequestContext) ResponseData {
|
||||||
// ---------------------
|
// ---------------------
|
||||||
|
|
||||||
baseData := getBaseData(c)
|
baseData := getBaseData(c)
|
||||||
baseData.Title = *c.CurrentProject.Name + " Forums"
|
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,
|
Name: c.CurrentProject.Name,
|
||||||
Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Subdomain()),
|
Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Slug),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: "Forums",
|
Name: "Forums",
|
||||||
|
@ -299,13 +300,17 @@ func ForumCategory(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
type forumThreadData struct {
|
type forumThreadData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
|
|
||||||
Thread templates.Thread
|
Thread templates.Thread
|
||||||
Posts []templates.Post
|
Posts []templates.Post
|
||||||
|
|
||||||
|
CategoryUrl string
|
||||||
|
ReplyUrl string
|
||||||
|
Pagination templates.Pagination
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForumThread(c *RequestContext) ResponseData {
|
func ForumThread(c *RequestContext) ResponseData {
|
||||||
const postsPerPage = 15
|
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"])
|
threadId, err := strconv.Atoi(c.PathParams["threadid"])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -319,10 +324,16 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{},
|
irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{},
|
||||||
`
|
`
|
||||||
SELECT $columns
|
SELECT $columns
|
||||||
FROM handmade_thread AS thread
|
FROM
|
||||||
WHERE thread.id = $1
|
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,
|
threadId,
|
||||||
|
c.CurrentProject.ID,
|
||||||
)
|
)
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -336,18 +347,46 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
categoryUrls := GetProjectCategoryUrls(c.Context(), c.Conn, c.CurrentProject.ID)
|
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 {
|
if !ok {
|
||||||
urlNoPage := ThreadUrl(thread, models.CatKindForum, categoryUrls[thread.CategoryID])
|
urlNoPage := ThreadUrl(thread, models.CatKindForum, categoryUrls[thread.CategoryID])
|
||||||
return c.Redirect(urlNoPage, http.StatusSeeOther)
|
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")
|
c.Perf.StartBlock("SQL", "Fetch posts")
|
||||||
type postsQueryResult struct {
|
type postsQueryResult struct {
|
||||||
Post models.Post `db:"post"`
|
Post models.Post `db:"post"`
|
||||||
Content string `db:"ver.text_parsed"`
|
Ver models.PostVersion `db:"ver"`
|
||||||
Author *models.User `db:"author"`
|
Author *models.User `db:"author"`
|
||||||
|
Editor *models.User `db:"editor"`
|
||||||
}
|
}
|
||||||
itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{},
|
itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{},
|
||||||
`
|
`
|
||||||
|
@ -356,8 +395,10 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
handmade_post AS post
|
handmade_post AS post
|
||||||
JOIN handmade_postversion AS ver ON post.current_id = ver.id
|
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 author ON post.author_id = author.id
|
||||||
|
LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id
|
||||||
WHERE
|
WHERE
|
||||||
post.thread_id = $1
|
post.thread_id = $1
|
||||||
|
AND NOT post.deleted
|
||||||
ORDER BY postdate
|
ORDER BY postdate
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
`,
|
`,
|
||||||
|
@ -374,18 +415,26 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
var posts []templates.Post
|
var posts []templates.Post
|
||||||
for _, irow := range itPosts.ToSlice() {
|
for _, irow := range itPosts.ToSlice() {
|
||||||
row := irow.(*postsQueryResult)
|
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)
|
baseData := getBaseData(c)
|
||||||
// TODO(asaf): Replace page title with thread title
|
baseData.Title = thread.Title
|
||||||
// TODO(asaf): Set breadcrumbs
|
// TODO(asaf): Set breadcrumbs
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("forum_thread.html", forumThreadData{
|
err = res.WriteTemplate("forum_thread.html", forumThreadData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
Thread: templates.ThreadToTemplate(&thread),
|
Thread: templates.ThreadToTemplate(&thread),
|
||||||
Posts: posts,
|
Posts: posts,
|
||||||
|
CategoryUrl: categoryUrls[thread.CategoryID],
|
||||||
|
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, subforums, thread.ID, *thread.FirstID),
|
||||||
|
Pagination: pagination,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
|
@ -75,7 +75,7 @@ func Index(c *RequestContext) ResponseData {
|
||||||
for _, projRow := range allProjects {
|
for _, projRow := range allProjects {
|
||||||
proj := projRow.(*models.Project)
|
proj := projRow.(*models.Project)
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", *proj.Name))
|
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name))
|
||||||
type projectPostQuery struct {
|
type projectPostQuery struct {
|
||||||
Post models.Post `db:"post"`
|
Post models.Post `db:"post"`
|
||||||
Thread models.Thread `db:"thread"`
|
Thread models.Thread `db:"thread"`
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/rs/zerolog"
|
"github.com/rs/zerolog"
|
||||||
|
@ -33,7 +34,7 @@ func TestLogContextErrors(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
routes.GET("^/test$", func(c *RequestContext) ResponseData {
|
routes.GET(regexp.MustCompile("^/test$"), func(c *RequestContext) ResponseData {
|
||||||
return ErrorResponse(http.StatusInternalServerError, err1, err2)
|
return ErrorResponse(http.StatusInternalServerError, err1, err2)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
@ -44,9 +44,10 @@ func GetProjectCategoryUrls(ctx context.Context, conn *pgxpool.Pool, projectId .
|
||||||
JOIN handmade_project AS project ON project.id = cat.project_id
|
JOIN handmade_project AS project ON project.id = cat.project_id
|
||||||
WHERE
|
WHERE
|
||||||
project.id = ANY ($1)
|
project.id = ANY ($1)
|
||||||
AND cat.kind != 6
|
AND cat.kind != $2
|
||||||
`, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check
|
`, // TODO(asaf): Clean up the db and remove the cat.kind != library resource check
|
||||||
projectId,
|
projectId,
|
||||||
|
models.CatKindLibraryResource,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -87,20 +88,20 @@ func makeCategoryUrls(rows []interface{}) map[int]string {
|
||||||
hierarchy[len(hierarchyReverse)-1-i] = hierarchyReverse[i]
|
hierarchy[len(hierarchyReverse)-1-i] = hierarchyReverse[i]
|
||||||
}
|
}
|
||||||
|
|
||||||
result[row.Cat.ID] = CategoryUrl(row.Project.Subdomain(), hierarchy...)
|
result[row.Cat.ID] = CategoryUrl(row.Project.Slug, hierarchy...)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func CategoryUrl(subdomain string, cats ...*models.Category) 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 {
|
for _, cat := range cats {
|
||||||
catNames = append(catNames, *cat.Name)
|
catSlugs = append(catSlugs, *cat.Slug)
|
||||||
}
|
}
|
||||||
switch cats[0].Kind {
|
switch cats[0].Kind {
|
||||||
case models.CatKindForum:
|
case models.CatKindForum:
|
||||||
return hmnurl.BuildForumCategory(subdomain, catNames[1:], 1)
|
return hmnurl.BuildForumCategory(projectSlug, catSlugs[1:], 1)
|
||||||
default:
|
default:
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue