Add blog indexes
This commit is contained in:
parent
b9645a6315
commit
c3e067fa44
|
@ -7669,7 +7669,6 @@ header {
|
||||||
.content {
|
.content {
|
||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
background-color: var(--content-background);
|
background-color: var(--content-background);
|
||||||
text-align: center;
|
|
||||||
margin: auto; }
|
margin: auto; }
|
||||||
.content p {
|
.content p {
|
||||||
-moz-text-size-adjust: auto;
|
-moz-text-size-adjust: auto;
|
||||||
|
@ -7875,7 +7874,9 @@ header {
|
||||||
|
|
||||||
.background-even:nth-of-type(even) {
|
.background-even:nth-of-type(even) {
|
||||||
background-color: #f8f8f8;
|
background-color: #f8f8f8;
|
||||||
background-color: var(--background-even-background); }
|
background-color: var(--background-even-background);
|
||||||
|
--fade-color: #f8f8f8;
|
||||||
|
--fade-color: var(--background-even-background); }
|
||||||
|
|
||||||
.userlist {
|
.userlist {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
@ -8552,6 +8553,14 @@ input[type=submit] {
|
||||||
background-color: #bbb;
|
background-color: #bbb;
|
||||||
background-color: var(--dimmest-color); }
|
background-color: var(--dimmest-color); }
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--fade-color: #f8f8f8;
|
||||||
|
--fade-color: var(--content-background); }
|
||||||
|
|
||||||
|
.excerpt-fade {
|
||||||
|
background-image: linear-gradient(to top, var(--fade-color), rgba(0, 0, 0, 0));
|
||||||
|
pointer-events: none; }
|
||||||
|
|
||||||
.read {
|
.read {
|
||||||
color: #555;
|
color: #555;
|
||||||
color: var(--forum-thread-read-color); }
|
color: var(--forum-thread-read-color); }
|
||||||
|
@ -9123,10 +9132,6 @@ span.icon-rss::before {
|
||||||
width: 10rem;
|
width: 10rem;
|
||||||
height: 10rem; } }
|
height: 10rem; } }
|
||||||
|
|
||||||
.landing .excerpt-fade {
|
|
||||||
background-image: linear-gradient(to top, var(--content-background), rgba(0, 0, 0, 0));
|
|
||||||
pointer-events: none; }
|
|
||||||
|
|
||||||
.star-btn {
|
.star-btn {
|
||||||
border-bottom-width: 2px;
|
border-bottom-width: 2px;
|
||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
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(DefaultNotSticky{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type DefaultNotSticky struct{}
|
||||||
|
|
||||||
|
func (m DefaultNotSticky) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 8, 3, 1, 48, 12, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DefaultNotSticky) Name() string {
|
||||||
|
return "DefaultNotSticky"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DefaultNotSticky) Description() string {
|
||||||
|
return "Make sticky default to false"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DefaultNotSticky) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_thread
|
||||||
|
ALTER sticky SET DEFAULT FALSE;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to set default")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m DefaultNotSticky) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
panic("Implement me")
|
||||||
|
}
|
|
@ -581,7 +581,6 @@ footer {
|
||||||
.content {
|
.content {
|
||||||
@include usevar(background-color, content-background);
|
@include usevar(background-color, content-background);
|
||||||
|
|
||||||
text-align:center;
|
|
||||||
margin:auto;
|
margin:auto;
|
||||||
|
|
||||||
p {
|
p {
|
||||||
|
@ -874,6 +873,7 @@ footer {
|
||||||
}
|
}
|
||||||
|
|
||||||
.background-even:nth-of-type(even) {
|
.background-even:nth-of-type(even) {
|
||||||
@include usevar(background-color, background-even-background);
|
|
||||||
// this is the default, and should be overridden by dynamic colors.
|
// this is the default, and should be overridden by dynamic colors.
|
||||||
|
@include usevar(background-color, background-even-background);
|
||||||
|
@include usevar(--fade-color, background-even-background);
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,6 +59,15 @@
|
||||||
@include usevar(background-color, dimmest-color);
|
@include usevar(background-color, dimmest-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
@include usevar(--fade-color, 'content-background');
|
||||||
|
}
|
||||||
|
|
||||||
|
.excerpt-fade {
|
||||||
|
background-image: linear-gradient(to top, var(--fade-color), rgba(0, 0, 0, 0));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.read {
|
.read {
|
||||||
@include usevar('color', 'forum-thread-read-color');
|
@include usevar('color', 'forum-thread-read-color');
|
||||||
|
|
||||||
|
|
|
@ -83,9 +83,4 @@
|
||||||
height: 10rem;
|
height: 10rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.excerpt-fade {
|
|
||||||
background-image: linear-gradient(to top, var(--content-background) , rgba(0, 0, 0, 0));
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,41 @@
|
||||||
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="optionbar">
|
||||||
|
<div class="options">
|
||||||
|
<a class="button" href="{{ .NewPostUrl }}"><span class="big pr1">+</span> Create Post</a>
|
||||||
|
</div>
|
||||||
|
<div class="options">
|
||||||
|
{{ template "pagination.html" .Pagination }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/* TODO: Breadcrumbs, or some other link back to the blog index */}}
|
||||||
|
{{ range .Posts }}
|
||||||
|
<div class="flex items-start ph3 pv3 background-even">
|
||||||
|
<img class="avatar-icon mr2" src="{{ .Author.AvatarUrl }}">
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<div class="title mb1"><a href="{{ .Url }}">{{ .Title }}</a></div>
|
||||||
|
<div class="details">
|
||||||
|
<a class="user" href="{{ .Author.ProfileUrl }}">{{ .Author.Name }}</a> — {{ timehtml (relativedate .Date) .Date }}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden mh-5 mt2 relative">
|
||||||
|
<div>
|
||||||
|
{{ .Content }}
|
||||||
|
</div>
|
||||||
|
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
|
||||||
|
</div>
|
||||||
|
<div class="mt2">
|
||||||
|
<a href="{{ .Url }}">Read More →</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
<div class="optionbar bottom">
|
||||||
|
<div class="options">
|
||||||
|
<a class="button" href="{{ .NewPostUrl }}"><span class="big pr1">+</span> Create Post</a>
|
||||||
|
</div>
|
||||||
|
<div class="options">
|
||||||
|
{{ template "pagination.html" .Pagination }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
|
@ -1,4 +1,4 @@
|
||||||
<footer class="pa3 pa4-l">
|
<footer class="pa3 pa4-l tc">
|
||||||
<h2>
|
<h2>
|
||||||
Community by <a href="{{ .Footer.HomepageUrl }}">handmade.network</a>
|
Community by <a href="{{ .Footer.HomepageUrl }}">handmade.network</a>
|
||||||
</h2>
|
</h2>
|
||||||
|
|
|
@ -2,16 +2,127 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"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"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func BlogIndex(c *RequestContext) ResponseData {
|
||||||
|
type blogIndexEntry struct {
|
||||||
|
Title string
|
||||||
|
Url string
|
||||||
|
Author templates.User
|
||||||
|
Date time.Time
|
||||||
|
Content template.HTML
|
||||||
|
}
|
||||||
|
type blogIndexData struct {
|
||||||
|
templates.BaseData
|
||||||
|
Posts []blogIndexEntry
|
||||||
|
Pagination templates.Pagination
|
||||||
|
NewPostUrl string
|
||||||
|
}
|
||||||
|
|
||||||
|
const postsPerPage = 5
|
||||||
|
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch count of posts")
|
||||||
|
numPosts, err := db.QueryInt(c.Context(), c.Conn,
|
||||||
|
`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM
|
||||||
|
handmade_thread
|
||||||
|
WHERE
|
||||||
|
project_id = $1
|
||||||
|
AND type = $2
|
||||||
|
AND NOT deleted
|
||||||
|
`,
|
||||||
|
c.CurrentProject.ID,
|
||||||
|
models.ThreadTypeProjectBlogPost,
|
||||||
|
)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch total number of blog posts"))
|
||||||
|
}
|
||||||
|
|
||||||
|
numPages := NumPages(numPosts, postsPerPage)
|
||||||
|
page, ok := ParsePageNumber(c, "page", numPages)
|
||||||
|
if !ok {
|
||||||
|
c.Redirect(hmnurl.BuildBlog(c.CurrentProject.Slug, page), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
type blogIndexQuery struct {
|
||||||
|
Thread models.Thread `db:"thread"`
|
||||||
|
Post models.Post `db:"post"`
|
||||||
|
CurrentVersion models.PostVersion `db:"ver"`
|
||||||
|
Author *models.User `db:"author"`
|
||||||
|
}
|
||||||
|
c.Perf.StartBlock("SQL", "Fetch blog posts")
|
||||||
|
postsResult, err := db.Query(c.Context(), c.Conn, blogIndexQuery{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_thread AS thread
|
||||||
|
JOIN handmade_post AS post ON thread.first_id = post.id
|
||||||
|
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.project_id = $1
|
||||||
|
AND post.thread_type = $2
|
||||||
|
AND NOT thread.deleted
|
||||||
|
ORDER BY post.postdate DESC
|
||||||
|
LIMIT $3 OFFSET $4
|
||||||
|
`,
|
||||||
|
c.CurrentProject.ID,
|
||||||
|
models.ThreadTypeProjectBlogPost,
|
||||||
|
postsPerPage,
|
||||||
|
(page-1)*postsPerPage,
|
||||||
|
)
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
if err != nil {
|
||||||
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch blog posts for index"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []blogIndexEntry
|
||||||
|
for _, irow := range postsResult.ToSlice() {
|
||||||
|
row := irow.(*blogIndexQuery)
|
||||||
|
|
||||||
|
entries = append(entries, blogIndexEntry{
|
||||||
|
Title: row.Thread.Title,
|
||||||
|
Url: hmnurl.BuildBlogThread(c.CurrentProject.Slug, row.Thread.ID, row.Thread.Title),
|
||||||
|
Author: templates.UserToTemplate(row.Author, c.Theme),
|
||||||
|
Date: row.Post.PostDate,
|
||||||
|
Content: template.HTML(row.CurrentVersion.TextParsed),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
baseData := getBaseData(c)
|
||||||
|
baseData.Title = fmt.Sprintf("%s Blog", c.CurrentProject.Name)
|
||||||
|
|
||||||
|
var res ResponseData
|
||||||
|
res.MustWriteTemplate("blog_index.html", blogIndexData{
|
||||||
|
BaseData: baseData,
|
||||||
|
Posts: entries,
|
||||||
|
Pagination: templates.Pagination{
|
||||||
|
Current: page,
|
||||||
|
Total: numPages,
|
||||||
|
|
||||||
|
FirstUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
|
||||||
|
LastUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, numPages),
|
||||||
|
PreviousUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page-1, numPages)),
|
||||||
|
NextUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page+1, numPages)),
|
||||||
|
},
|
||||||
|
NewPostUrl: hmnurl.BuildBlogNewThread(c.CurrentProject.Slug),
|
||||||
|
}, c.Perf)
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
|
||||||
func BlogThread(c *RequestContext) ResponseData {
|
func BlogThread(c *RequestContext) ResponseData {
|
||||||
type blogPostData struct {
|
type blogPostData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
|
|
|
@ -2,7 +2,6 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
@ -99,21 +98,11 @@ func Forum(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
numPages := utils.IntMax(int(math.Ceil(float64(numThreads)/threadsPerPage)), 1)
|
numPages := NumPages(numThreads, threadsPerPage)
|
||||||
|
page, ok := ParsePageNumber(c, "page", numPages)
|
||||||
page := 1
|
if !ok {
|
||||||
pageString, hasPage := c.PathParams["page"]
|
c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, page), http.StatusSeeOther)
|
||||||
if hasPage && pageString != "" {
|
|
||||||
if pageParsed, err := strconv.Atoi(pageString); err == nil {
|
|
||||||
page = pageParsed
|
|
||||||
} else {
|
|
||||||
return c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if page < 1 || numPages < page {
|
|
||||||
return c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page, numPages)), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
howManyThreadsToSkip := (page - 1) * threadsPerPage
|
howManyThreadsToSkip := (page - 1) * threadsPerPage
|
||||||
|
|
||||||
var currentUserId *int
|
var currentUserId *int
|
||||||
|
|
|
@ -0,0 +1,34 @@
|
||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
/*
|
||||||
|
Parses a path param as a page number, and returns the parsed result and
|
||||||
|
a value indicating whether parsing was successful.
|
||||||
|
|
||||||
|
The returned page number is always valid, even when parsing fails. If
|
||||||
|
parsing fails (ok is false), you should redirect to the returned
|
||||||
|
page number.
|
||||||
|
*/
|
||||||
|
func ParsePageNumber(
|
||||||
|
c *RequestContext,
|
||||||
|
paramName string,
|
||||||
|
numPages int,
|
||||||
|
) (page int, ok bool) {
|
||||||
|
if pageString, hasPage := c.PathParams[paramName]; hasPage && pageString != "" {
|
||||||
|
if pageParsed, err := strconv.Atoi(pageString); err == nil {
|
||||||
|
page = pageParsed
|
||||||
|
} else {
|
||||||
|
return 1, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if page < 1 || numPages < page {
|
||||||
|
return utils.IntClamp(1, page, numPages), false
|
||||||
|
}
|
||||||
|
|
||||||
|
return page, true
|
||||||
|
}
|
|
@ -166,6 +166,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
|
||||||
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
mainRoutes.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
||||||
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
mainRoutes.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
||||||
|
|
||||||
|
mainRoutes.GET(hmnurl.RegexBlog, BlogIndex)
|
||||||
mainRoutes.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
mainRoutes.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
||||||
mainRoutes.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
mainRoutes.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
||||||
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
|
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
|
||||||
|
|
|
@ -1,10 +1,17 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
|
|
||||||
"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"
|
||||||
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func NumPages(numThings, thingsPerPage int) int {
|
||||||
|
return utils.IntMax(int(math.Ceil(float64(numThings)/float64(thingsPerPage))), 1)
|
||||||
|
}
|
||||||
|
|
||||||
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
|
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case models.ThreadTypeProjectBlogPost:
|
case models.ThreadTypeProjectBlogPost:
|
||||||
|
|
Loading…
Reference in New Issue