Add blog indexes
This commit is contained in:
parent
b9645a6315
commit
c3e067fa44
|
@ -7669,7 +7669,6 @@ header {
|
|||
.content {
|
||||
background-color: #f8f8f8;
|
||||
background-color: var(--content-background);
|
||||
text-align: center;
|
||||
margin: auto; }
|
||||
.content p {
|
||||
-moz-text-size-adjust: auto;
|
||||
|
@ -7875,7 +7874,9 @@ header {
|
|||
|
||||
.background-even:nth-of-type(even) {
|
||||
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 {
|
||||
text-align: center;
|
||||
|
@ -8552,6 +8553,14 @@ input[type=submit] {
|
|||
background-color: #bbb;
|
||||
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 {
|
||||
color: #555;
|
||||
color: var(--forum-thread-read-color); }
|
||||
|
@ -9123,10 +9132,6 @@ span.icon-rss::before {
|
|||
width: 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 {
|
||||
border-bottom-width: 2px;
|
||||
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 {
|
||||
@include usevar(background-color, content-background);
|
||||
|
||||
text-align:center;
|
||||
margin:auto;
|
||||
|
||||
p {
|
||||
|
@ -874,6 +873,7 @@ footer {
|
|||
}
|
||||
|
||||
.background-even:nth-of-type(even) {
|
||||
@include usevar(background-color, background-even-background);
|
||||
// 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);
|
||||
}
|
||||
|
||||
: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 {
|
||||
@include usevar('color', 'forum-thread-read-color');
|
||||
|
||||
|
|
|
@ -83,9 +83,4 @@
|
|||
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>
|
||||
Community by <a href="{{ .Footer.HomepageUrl }}">handmade.network</a>
|
||||
</h2>
|
||||
|
|
|
@ -2,16 +2,127 @@ package website
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"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"
|
||||
)
|
||||
|
||||
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 {
|
||||
type blogPostData struct {
|
||||
templates.BaseData
|
||||
|
|
|
@ -2,7 +2,6 @@ package website
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
@ -99,21 +98,11 @@ func Forum(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
numPages := utils.IntMax(int(math.Ceil(float64(numThreads)/threadsPerPage)), 1)
|
||||
|
||||
page := 1
|
||||
pageString, hasPage := c.PathParams["page"]
|
||||
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)
|
||||
numPages := NumPages(numThreads, threadsPerPage)
|
||||
page, ok := ParsePageNumber(c, "page", numPages)
|
||||
if !ok {
|
||||
c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, page), 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
|
||||
|
||||
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.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
||||
|
||||
mainRoutes.GET(hmnurl.RegexBlog, BlogIndex)
|
||||
mainRoutes.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
||||
mainRoutes.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
||||
mainRoutes.GET(hmnurl.RegexBlogThread, BlogThread)
|
||||
|
|
|
@ -1,10 +1,17 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"math"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"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 {
|
||||
switch kind {
|
||||
case models.ThreadTypeProjectBlogPost:
|
||||
|
|
Loading…
Reference in New Issue