diff --git a/src/hmnurl/hmnurl.go b/src/hmnurl/hmnurl.go index 8cc0886..0713542 100644 --- a/src/hmnurl/hmnurl.go +++ b/src/hmnurl/hmnurl.go @@ -4,6 +4,7 @@ import ( "net/url" "git.handmade.network/hmn/hmn/src/config" + "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" ) @@ -37,10 +38,15 @@ func Url(path string, query []Q) string { 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 if len(subdomain) > 0 { - host = subdomain + "." + host + host = slug + "." + host } url := url.URL{ diff --git a/src/hmnurl/urls.go b/src/hmnurl/urls.go index 12a9f8b..5e6cbf3 100644 --- a/src/hmnurl/urls.go +++ b/src/hmnurl/urls.go @@ -1,73 +1,74 @@ package hmnurl import ( - "git.handmade.network/hmn/hmn/src/oops" "regexp" "strconv" "strings" + + "git.handmade.network/hmn/hmn/src/oops" ) -var RegexHomepage *regexp.Regexp = regexp.MustCompile("^/$") +var RegexHomepage = regexp.MustCompile("^/$") func BuildHomepage() string { return Url("/", nil) } -var RegexLogin *regexp.Regexp = regexp.MustCompile("^/login$") +var RegexLogin = regexp.MustCompile("^/login$") func BuildLogin() string { return Url("/login", nil) } -var RegexLogout *regexp.Regexp = regexp.MustCompile("^/logout$") +var RegexLogout = regexp.MustCompile("^/logout$") func BuildLogout() string { return Url("/logout", nil) } -var RegexManifesto *regexp.Regexp = regexp.MustCompile("^/manifesto$") +var RegexManifesto = regexp.MustCompile("^/manifesto$") func BuildManifesto() string { return Url("/manifesto", nil) } -var RegexAbout *regexp.Regexp = regexp.MustCompile("^/about$") +var RegexAbout = regexp.MustCompile("^/about$") func BuildAbout() string { return Url("/about", nil) } -var RegexCodeOfConduct *regexp.Regexp = regexp.MustCompile("^/code-of-conduct$") +var RegexCodeOfConduct = regexp.MustCompile("^/code-of-conduct$") func BuildCodeOfConduct() string { return Url("/code-of-conduct", nil) } -var RegexCommunicationGuidelines *regexp.Regexp = regexp.MustCompile("^/communication-guidelines$") +var RegexCommunicationGuidelines = regexp.MustCompile("^/communication-guidelines$") func BuildCommunicationGuidelines() string { return Url("/communication-guidelines", nil) } -var RegexContactPage *regexp.Regexp = regexp.MustCompile("^/contact$") +var RegexContactPage = regexp.MustCompile("^/contact$") func BuildContactPage() string { return Url("/contact", nil) } -var RegexMonthlyUpdatePolicy *regexp.Regexp = regexp.MustCompile("^/monthly-update-policy$") +var RegexMonthlyUpdatePolicy = regexp.MustCompile("^/monthly-update-policy$") func BuildMonthlyUpdatePolicy() string { return Url("/monthly-update-policy", nil) } -var RegexProjectSubmissionGuidelines *regexp.Regexp = regexp.MustCompile("^/project-guidelines$") +var RegexProjectSubmissionGuidelines = regexp.MustCompile("^/project-guidelines$") func BuildProjectSubmissionGuidelines() string { return Url("/project-guidelines", nil) } -var RegexFeed *regexp.Regexp = regexp.MustCompile(`^/feed(/(?P.+)?)?$`) +var RegexFeed = regexp.MustCompile(`^/feed(/(?P.+)?)?$`) func BuildFeed() string { return Url("/feed", nil) @@ -83,13 +84,9 @@ func BuildFeedWithPage(page int) string { return Url("/feed/"+strconv.Itoa(page), nil) } -var RegexForumThread *regexp.Regexp = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`) +var RegexForumThread = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)/t/(?P\d+)(/(?P\d+))?$`) func BuildForumThread(projectSlug string, subforums []string, threadId int, page int) string { - if projectSlug == "hmn" { - projectSlug = "" - } - if page < 1 { 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) } -var RegexForumCategory *regexp.Regexp = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`) +var RegexForumCategory = regexp.MustCompile(`^/(?Pforums(/[^\d]+?)*)(/(?P\d+))?$`) func BuildForumCategory(projectSlug string, subforums []string, page int) string { - if projectSlug == "hmn" { - projectSlug = "" - } - if page < 1 { 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) } -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 { - if projectSlug == "hmn" { - projectSlug = "" - } - var builder strings.Builder builder.WriteString("/forums") for _, subforum := range subforums { @@ -177,13 +166,38 @@ func BuildForumPost(projectSlug string, subforums []string, threadId int, postId 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 { - return Url("/assets/project.css", []Q{Q{"color", color}}) +func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string { + 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 { filepath = strings.Trim(filepath, "/") @@ -204,4 +218,4 @@ func BuildPublic(filepath string) string { return Url(builder.String(), nil) } -var RegexCatchAll *regexp.Regexp = regexp.MustCompile("") +var RegexCatchAll = regexp.MustCompile("") diff --git a/src/migration/migrations/2021-05-06T031328Z_RemoveProjectNulls.go b/src/migration/migrations/2021-05-06T031328Z_RemoveProjectNulls.go new file mode 100644 index 0000000..7352903 --- /dev/null +++ b/src/migration/migrations/2021-05-06T031328Z_RemoveProjectNulls.go @@ -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") +} diff --git a/src/models/category.go b/src/models/category.go index d9241dd..0972b95 100644 --- a/src/models/category.go +++ b/src/models/category.go @@ -34,13 +34,66 @@ type Category struct { 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 { - Tree map[int]*CategoryTreeNode + Tree CategoryTree CategoryCache map[int][]*Category SlugCache map[int][]string } -func MakeCategoryLineageBuilder(fullCategoryTree map[int]*CategoryTreeNode) *CategoryLineageBuilder { +func MakeCategoryLineageBuilder(fullCategoryTree CategoryTree) *CategoryLineageBuilder { return &CategoryLineageBuilder{ Tree: fullCategoryTree, CategoryCache: make(map[int][]*Category), @@ -72,93 +125,3 @@ func (cl *CategoryLineageBuilder) GetLineageSlugs(catId int) []string { } 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 -} diff --git a/src/models/project.go b/src/models/project.go index 5c25cc8..9591366 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -5,7 +5,10 @@ import ( "time" ) -const HMNProjectID = 1 +const ( + HMNProjectID = 1 + HMNProjectSlug = "hmn" +) var ProjectType = reflect.TypeOf(Project{}) @@ -24,10 +27,10 @@ const ( type Project struct { ID int `db:"id"` - Slug *string `db:"slug"` // TODO: Migrate these to NOT NULL - Name *string `db:"name"` - Blurb *string `db:"blurb"` - Description *string `db:"description"` + Slug string `db:"slug"` + Name string `db:"name"` + Blurb string `db:"blurb"` + Description string `db:"description"` Lifecycle ProjectLifecycle `db:"lifecycle"` @@ -46,5 +49,5 @@ func (p *Project) Subdomain() string { return "" } - return *p.Slug + return p.Slug } diff --git a/src/templates/mapping.go b/src/templates/mapping.go index fea2707..4dcab5e 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -2,6 +2,7 @@ package templates import ( "html/template" + "net" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -15,30 +16,44 @@ func PostToTemplate(p *models.Post, author *models.User) Post { } return Post{ - ID: p.ID, - Url: "nope", // TODO + ID: p.ID, + + // Urls not set here. See AddUrls. Preview: p.Preview, ReadOnly: p.ReadOnly, 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, IP: p.IP.String(), } } -func PostToTemplateWithContent(p *models.Post, author *models.User, content string) Post { - post := PostToTemplate(p, author) - post.Content = template.HTML(content) +func (p *Post) AddContentVersion(ver models.PostVersion, editor *models.User) { + p.Content = template.HTML(ver.TextParsed) - 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 { return Project{ - Name: maybeString(p.Name), + Name: p.Name, Subdomain: p.Subdomain(), Color1: p.Color1, Color2: p.Color2, @@ -103,3 +118,11 @@ func maybeString(s *string) string { } return *s } + +func maybeIp(ip *net.IPNet) string { + if ip == nil { + return "" + } + + return ip.String() +} diff --git a/src/templates/src/forum_thread.html b/src/templates/src/forum_thread.html index 034ad95..c2b34ae 100644 --- a/src/templates/src/forum_thread.html +++ b/src/templates/src/forum_thread.html @@ -2,6 +2,10 @@ {{ define "content" }}
+
+ ← Back to index + {{ template "pagination.html" .Pagination }} +
{{ range .Posts }}
{{/* TODO: Dynamically switch between bbcode and markdown */}}
@@ -62,28 +66,28 @@ {{ 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 + {{ timehtml (relativedate .PostDate) .PostDate }} {{ if .Editor }} Edited by {{ coalesce .Editor.Name .Editor.Username }} {{ if and $.User.IsStaff .EditIP }}[{{ .EditIP }}]{{ end }} - on {{ .EditDate }} + on {{ timehtml (absolutedate .EditDate) .EditDate }} {{ with .EditReason }} Reason: {{ . }} {{ end }} @@ -108,5 +112,20 @@
{{ end }} +
+
+ ← Back to index + {{ if .Thread.Locked }} + Thread is locked. + {{ else if .User }} + ⤷ Reply to Thread + {{ else }} + Log in to reply + {{ end }} +
+
+ {{ template "pagination.html" .Pagination }} +
+
{{ end }} diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index 0e31e9b..53ec3fc 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -87,5 +87,10 @@ loginPopup.classList.toggle("open"); } } + + for (const time of document.querySelectorAll('time')) { + const d = new Date(Date.parse(time.dateTime)); + time.title = d.toLocaleString(); + } }); diff --git a/src/templates/src/include/post_list_item.html b/src/templates/src/include/post_list_item.html index 5a9fda0..a6f4e27 100644 --- a/src/templates/src/include/post_list_item.html +++ b/src/templates/src/include/post_list_item.html @@ -15,7 +15,7 @@ It should be called with PostListItem.
- {{ .User.Name }}{{ relativedate .Date }} + {{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }}
{{ with .Content }}
diff --git a/src/templates/src/include/thread_list_item.html b/src/templates/src/include/thread_list_item.html index df05fe9..cad6dcc 100644 --- a/src/templates/src/include/thread_list_item.html +++ b/src/templates/src/include/thread_list_item.html @@ -15,7 +15,7 @@ It should be called with ThreadListItem.
- {{ .FirstUser.Name }}{{ relativedate .FirstDate }} + {{ .FirstUser.Name }} — {{ timehtml (relativedate .FirstDate) .FirstDate }}
{{ with .Content }}
@@ -26,7 +26,7 @@ It should be called with ThreadListItem.
-
Last post {{ relativedate .LastDate }}
+
Last post {{ timehtml (relativedate .LastDate) .LastDate }}
{{ .LastUser.Name }}
diff --git a/src/templates/src/landing.html b/src/templates/src/landing.html index 2b76876..e962904 100644 --- a/src/templates/src/landing.html +++ b/src/templates/src/landing.html @@ -271,7 +271,7 @@
- {{ .User.Name }}{{ relativedate .Date }} + {{ .User.Name }} — {{ timehtml (relativedate .Date) .Date }}
diff --git a/src/templates/src/layouts/base.html b/src/templates/src/layouts/base.html index 1cd0030..35d36d0 100644 --- a/src/templates/src/layouts/base.html +++ b/src/templates/src/layouts/base.html @@ -12,7 +12,7 @@ {{ end }} {{ end }} {{ if .Title }} - {{ .Title }} | Handmade Network + {{ .Title }} | Handmade Network {{/* TODO: Some parts of the site replace "Handmade Network" with other things like "4coder Forums". */}} {{ else }} Handmade Network {{ end }} diff --git a/src/templates/templates.go b/src/templates/templates.go index 543f829..a9403c8 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -67,6 +67,9 @@ func names(ts []*template.Template) []string { } 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 { color.Alpha = alpha return color @@ -157,6 +160,10 @@ var HMNTemplateFuncs = template.FuncMap{ "staticthemenobust": func(theme string, filepath string) string { 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(``, iso, formatted)) + }, "url": func(url string) string { return hmnurl.Url(url, nil) }, diff --git a/src/templates/types.go b/src/templates/types.go index 9725294..149a533 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -26,8 +26,13 @@ type Thread struct { } type Post struct { - ID int - Url string + ID int + + Url string + DeleteUrl string + EditUrl string + ReplyUrl string + QuoteUrl string Preview string ReadOnly bool diff --git a/src/website/feed.go b/src/website/feed.go index 289d86d..6db6545 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -124,13 +124,13 @@ func Feed(c *RequestContext) ResponseData { c.Perf.EndBlock() 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] if !ok { lineageNames := lineageBuilder.GetLineageSlugs(cat.ID) switch cat.Kind { 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!!! default: categoryUrlCache[cat.ID] = "" @@ -153,8 +153,8 @@ func Feed(c *RequestContext) ResponseData { breadcrumbs := make([]templates.Breadcrumb, 0, len(lineageBuilder.GetLineage(postResult.Cat.ID))) breadcrumbs = append(breadcrumbs, templates.Breadcrumb{ - Name: *postResult.Proj.Name, - Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Subdomain()), + Name: postResult.Proj.Name, + Url: hmnurl.ProjectUrl("/", nil, postResult.Proj.Slug), }) if postResult.Post.CategoryKind == models.CatKindLibraryResource { // TODO(asaf): Fetch library root topic for the project and construct breadcrumb for it diff --git a/src/website/forums.go b/src/website/forums.go index 501b46a..258ba56 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -15,6 +15,7 @@ import ( "git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/templates" + "git.handmade.network/hmn/hmn/src/utils" "github.com/jackc/pgx/v4/pgxpool" ) @@ -261,11 +262,11 @@ func ForumCategory(c *RequestContext) ResponseData { // --------------------- baseData := getBaseData(c) - baseData.Title = *c.CurrentProject.Name + " Forums" - baseData.Breadcrumbs = []templates.Breadcrumb{ + baseData.Title = c.CurrentProject.Name + " Forums" + baseData.Breadcrumbs = []templates.Breadcrumb{ // TODO(ben): This is wrong; it needs to account for subcategories. { - Name: *c.CurrentProject.Name, - Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Subdomain()), + Name: c.CurrentProject.Name, + Url: hmnurl.ProjectUrl("/", nil, c.CurrentProject.Slug), }, { Name: "Forums", @@ -299,13 +300,17 @@ func ForumCategory(c *RequestContext) ResponseData { type forumThreadData struct { templates.BaseData + Thread templates.Thread Posts []templates.Post + + CategoryUrl string + ReplyUrl string + Pagination templates.Pagination } func ForumThread(c *RequestContext) ResponseData { 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"]) if err != nil { @@ -319,10 +324,16 @@ func ForumThread(c *RequestContext) ResponseData { irow, err := db.QueryOne(c.Context(), c.Conn, threadQueryResult{}, ` SELECT $columns - FROM handmade_thread AS thread - WHERE thread.id = $1 + FROM + 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, + c.CurrentProject.ID, ) c.Perf.EndBlock() if err != nil { @@ -336,18 +347,46 @@ func ForumThread(c *RequestContext) ResponseData { 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 { urlNoPage := ThreadUrl(thread, models.CatKindForum, categoryUrls[thread.CategoryID]) 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") type postsQueryResult struct { - Post models.Post `db:"post"` - Content string `db:"ver.text_parsed"` - Author *models.User `db:"author"` + Post models.Post `db:"post"` + Ver models.PostVersion `db:"ver"` + Author *models.User `db:"author"` + Editor *models.User `db:"editor"` } itPosts, err := db.Query(c.Context(), c.Conn, postsQueryResult{}, ` @@ -356,8 +395,10 @@ func ForumThread(c *RequestContext) ResponseData { 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 + LEFT JOIN auth_user AS editor ON ver.editor_id = editor.id WHERE post.thread_id = $1 + AND NOT post.deleted ORDER BY postdate LIMIT $2 OFFSET $3 `, @@ -374,18 +415,26 @@ func ForumThread(c *RequestContext) ResponseData { var posts []templates.Post for _, irow := range itPosts.ToSlice() { 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) - // TODO(asaf): Replace page title with thread title + baseData.Title = thread.Title // TODO(asaf): Set breadcrumbs var res ResponseData err = res.WriteTemplate("forum_thread.html", forumThreadData{ - BaseData: baseData, - Thread: templates.ThreadToTemplate(&thread), - Posts: posts, + BaseData: baseData, + Thread: templates.ThreadToTemplate(&thread), + Posts: posts, + CategoryUrl: categoryUrls[thread.CategoryID], + ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, subforums, thread.ID, *thread.FirstID), + Pagination: pagination, }, c.Perf) if err != nil { panic(err) diff --git a/src/website/landing.go b/src/website/landing.go index 98e508a..cae87e6 100644 --- a/src/website/landing.go +++ b/src/website/landing.go @@ -75,7 +75,7 @@ func Index(c *RequestContext) ResponseData { for _, projRow := range allProjects { 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 { Post models.Post `db:"post"` Thread models.Thread `db:"thread"` diff --git a/src/website/routes_test.go b/src/website/routes_test.go index 86ea652..0e4d088 100644 --- a/src/website/routes_test.go +++ b/src/website/routes_test.go @@ -5,6 +5,7 @@ import ( "errors" "net/http" "net/http/httptest" + "regexp" "testing" "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) }) diff --git a/src/website/urls.go b/src/website/urls.go index 7fce5fa..f672e84 100644 --- a/src/website/urls.go +++ b/src/website/urls.go @@ -44,9 +44,10 @@ func GetProjectCategoryUrls(ctx context.Context, conn *pgxpool.Pool, projectId . JOIN handmade_project AS project ON project.id = cat.project_id WHERE project.id = ANY ($1) - AND cat.kind != 6 - `, // TODO(asaf): Clean up the db and remove the cat.kind != 6 check + AND cat.kind != $2 + `, // TODO(asaf): Clean up the db and remove the cat.kind != library resource check projectId, + models.CatKindLibraryResource, ) if err != nil { panic(err) @@ -87,20 +88,20 @@ func makeCategoryUrls(rows []interface{}) map[int]string { 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 } -func CategoryUrl(subdomain string, cats ...*models.Category) string { - catNames := make([]string, 0, len(cats)) +func CategoryUrl(projectSlug string, cats ...*models.Category) string { + catSlugs := make([]string, 0, len(cats)) for _, cat := range cats { - catNames = append(catNames, *cat.Name) + catSlugs = append(catSlugs, *cat.Slug) } switch cats[0].Kind { case models.CatKindForum: - return hmnurl.BuildForumCategory(subdomain, catNames[1:], 1) + return hmnurl.BuildForumCategory(projectSlug, catSlugs[1:], 1) default: return "" }