447 lines
14 KiB
Go
447 lines
14 KiB
Go
package website
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"math/rand"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"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"
|
|
)
|
|
|
|
type ProjectTemplateData struct {
|
|
templates.BaseData
|
|
|
|
Pagination templates.Pagination
|
|
CarouselProjects []templates.Project
|
|
Projects []templates.Project
|
|
|
|
UserPendingProjectUnderReview bool
|
|
UserPendingProject *templates.Project
|
|
UserApprovedProjects []templates.Project
|
|
|
|
ProjectAtomFeedUrl string
|
|
ManifestoUrl string
|
|
NewProjectUrl string
|
|
RegisterUrl string
|
|
LoginUrl string
|
|
}
|
|
|
|
func ProjectIndex(c *RequestContext) ResponseData {
|
|
const projectsPerPage = 20
|
|
const maxCarouselProjects = 10
|
|
|
|
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.BuildProjectIndex(1), http.StatusSeeOther)
|
|
}
|
|
}
|
|
|
|
if page < 1 {
|
|
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
|
|
}
|
|
|
|
c.Perf.StartBlock("SQL", "Fetching all visible projects")
|
|
type projectResult struct {
|
|
Project models.Project `db:"project"`
|
|
}
|
|
allProjects, err := db.Query(c.Context(), c.Conn, projectResult{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_project AS project
|
|
WHERE
|
|
project.lifecycle = ANY($1)
|
|
AND project.flags = 0
|
|
ORDER BY project.date_approved ASC
|
|
`,
|
|
models.VisibleProjectLifecycles,
|
|
)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects"))
|
|
}
|
|
allProjectsSlice := allProjects.ToSlice()
|
|
c.Perf.EndBlock()
|
|
|
|
numPages := int(math.Ceil(float64(len(allProjectsSlice)) / projectsPerPage))
|
|
|
|
if page > numPages {
|
|
return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther)
|
|
}
|
|
|
|
pagination := templates.Pagination{
|
|
Current: page,
|
|
Total: numPages,
|
|
|
|
FirstUrl: hmnurl.BuildProjectIndex(1),
|
|
LastUrl: hmnurl.BuildProjectIndex(numPages),
|
|
NextUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page+1, numPages)),
|
|
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)),
|
|
}
|
|
|
|
var userApprovedProjects []templates.Project
|
|
var userPendingProject *templates.Project
|
|
userPendingProjectUnderReview := false
|
|
if c.CurrentUser != nil {
|
|
c.Perf.StartBlock("SQL", "fetching user projects")
|
|
type UserProjectQuery struct {
|
|
Project models.Project `db:"project"`
|
|
}
|
|
userProjectsResult, err := db.Query(c.Context(), c.Conn, UserProjectQuery{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_project AS project
|
|
INNER JOIN handmade_project_groups AS project_groups ON project_groups.project_id = project.id
|
|
INNER JOIN auth_user_groups AS user_groups ON user_groups.group_id = project_groups.group_id
|
|
WHERE
|
|
user_groups.user_id = $1
|
|
`,
|
|
c.CurrentUser.ID,
|
|
)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
|
|
}
|
|
for _, project := range userProjectsResult.ToSlice() {
|
|
p := project.(*UserProjectQuery).Project
|
|
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
|
|
if userPendingProject == nil {
|
|
// NOTE(asaf): Technically a user could have more than one pending project.
|
|
// For example, if they created one project themselves and were added as an additional owner to another user's project.
|
|
// So we'll just take the first one. I don't think it matters. I guess it especially won't matter after Projects 2.0.
|
|
tmplProject := templates.ProjectToTemplate(&p, c.Theme)
|
|
userPendingProject = &tmplProject
|
|
userPendingProjectUnderReview = (p.Lifecycle == models.ProjectLifecycleApprovalRequired)
|
|
}
|
|
} else {
|
|
userApprovedProjects = append(userApprovedProjects, templates.ProjectToTemplate(&p, c.Theme))
|
|
}
|
|
}
|
|
c.Perf.EndBlock()
|
|
}
|
|
|
|
c.Perf.StartBlock("PROJECTS", "Grouping and sorting")
|
|
var handmadeHero *templates.Project
|
|
var featuredProjects []templates.Project
|
|
var recentProjects []templates.Project
|
|
var restProjects []templates.Project
|
|
now := time.Now()
|
|
for _, p := range allProjectsSlice {
|
|
project := &p.(*projectResult).Project
|
|
templateProject := templates.ProjectToTemplate(project, c.Theme)
|
|
if project.Slug == "hero" {
|
|
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
|
|
handmadeHero = &templateProject
|
|
continue
|
|
}
|
|
if project.Featured {
|
|
featuredProjects = append(featuredProjects, templateProject)
|
|
} else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
|
|
recentProjects = append(recentProjects, templateProject)
|
|
} else {
|
|
restProjects = append(restProjects, templateProject)
|
|
}
|
|
}
|
|
|
|
_, randSeed := now.ISOWeek()
|
|
random := rand.New(rand.NewSource(int64(randSeed)))
|
|
random.Shuffle(len(featuredProjects), func(i, j int) { featuredProjects[i], featuredProjects[j] = featuredProjects[j], featuredProjects[i] })
|
|
random.Shuffle(len(recentProjects), func(i, j int) { recentProjects[i], recentProjects[j] = recentProjects[j], recentProjects[i] })
|
|
random.Shuffle(len(restProjects), func(i, j int) { restProjects[i], restProjects[j] = restProjects[j], restProjects[i] })
|
|
|
|
if handmadeHero != nil {
|
|
// NOTE(asaf): As mentioned above, inserting HMH first.
|
|
featuredProjects = append([]templates.Project{*handmadeHero}, featuredProjects...)
|
|
}
|
|
|
|
orderedProjects := make([]templates.Project, 0, len(featuredProjects)+len(recentProjects)+len(restProjects))
|
|
orderedProjects = append(orderedProjects, featuredProjects...)
|
|
orderedProjects = append(orderedProjects, recentProjects...)
|
|
orderedProjects = append(orderedProjects, restProjects...)
|
|
|
|
firstProjectIndex := (page - 1) * projectsPerPage
|
|
endIndex := utils.IntMin(firstProjectIndex+projectsPerPage, len(orderedProjects))
|
|
pageProjects := orderedProjects[firstProjectIndex:endIndex]
|
|
|
|
var carouselProjects []templates.Project
|
|
if page == 1 {
|
|
carouselProjects = featuredProjects[:utils.IntMin(len(featuredProjects), maxCarouselProjects)]
|
|
}
|
|
c.Perf.EndBlock()
|
|
|
|
baseData := getBaseData(c)
|
|
baseData.Title = "Project List"
|
|
var res ResponseData
|
|
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
|
|
BaseData: baseData,
|
|
|
|
Pagination: pagination,
|
|
CarouselProjects: carouselProjects,
|
|
Projects: pageProjects,
|
|
|
|
UserPendingProjectUnderReview: userPendingProjectUnderReview,
|
|
UserPendingProject: userPendingProject,
|
|
UserApprovedProjects: userApprovedProjects,
|
|
|
|
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
|
|
ManifestoUrl: hmnurl.BuildManifesto(),
|
|
NewProjectUrl: hmnurl.BuildProjectNew(),
|
|
RegisterUrl: hmnurl.BuildRegister(),
|
|
LoginUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
|
}, c.Perf)
|
|
return res
|
|
}
|
|
|
|
type ProjectHomepageData struct {
|
|
templates.BaseData
|
|
Project templates.Project
|
|
Owners []templates.User
|
|
Notices []templates.Notice
|
|
Screenshots []string
|
|
ProjectLinks []templates.Link
|
|
Licenses []templates.Link
|
|
RecentActivity []templates.TimelineItem
|
|
}
|
|
|
|
func ProjectHomepage(c *RequestContext) ResponseData {
|
|
maxRecentActivity := 15
|
|
var project *models.Project
|
|
|
|
if c.CurrentProject.IsHMN() {
|
|
slug, hasSlug := c.PathParams["slug"]
|
|
if hasSlug && slug != "" {
|
|
slug = strings.ToLower(slug)
|
|
if slug == models.HMNProjectSlug {
|
|
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
|
}
|
|
c.Perf.StartBlock("SQL", "Fetching project by slug")
|
|
type projectQuery struct {
|
|
Project models.Project `db:"Project"`
|
|
}
|
|
projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_project AS project
|
|
WHERE
|
|
LOWER(project.slug) = $1
|
|
`,
|
|
slug,
|
|
)
|
|
c.Perf.EndBlock()
|
|
if err != nil {
|
|
if errors.Is(err, db.ErrNoMatchingRows) {
|
|
return FourOhFour(c)
|
|
} else {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug"))
|
|
}
|
|
}
|
|
project = &projectQueryResult.(*projectQuery).Project
|
|
if project.Lifecycle != models.ProjectLifecycleUnapproved && project.Lifecycle != models.ProjectLifecycleApprovalRequired {
|
|
return c.Redirect(hmnurl.BuildProjectHomepage(project.Slug), http.StatusSeeOther)
|
|
}
|
|
}
|
|
} else {
|
|
project = c.CurrentProject
|
|
}
|
|
|
|
if project == nil {
|
|
return FourOhFour(c)
|
|
}
|
|
|
|
owners, err := FetchProjectOwners(c, project.ID)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, err)
|
|
}
|
|
|
|
canView := false
|
|
canEdit := false
|
|
if c.CurrentUser != nil {
|
|
if c.CurrentUser.IsStaff {
|
|
canView = true
|
|
canEdit = true
|
|
} else {
|
|
for _, owner := range owners {
|
|
if owner.ID == c.CurrentUser.ID {
|
|
canView = true
|
|
canEdit = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !canView {
|
|
if project.Flags == 0 {
|
|
for _, lc := range models.VisibleProjectLifecycles {
|
|
if project.Lifecycle == lc {
|
|
canView = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if !canView {
|
|
return FourOhFour(c)
|
|
}
|
|
|
|
c.Perf.StartBlock("SQL", "Fetching screenshots")
|
|
type screenshotQuery struct {
|
|
Filename string `db:"screenshot.file"`
|
|
}
|
|
screenshotQueryResult, err := db.Query(c.Context(), c.Conn, screenshotQuery{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_imagefile AS screenshot
|
|
INNER JOIN handmade_project_screenshots ON screenshot.id = handmade_project_screenshots.imagefile_id
|
|
WHERE
|
|
handmade_project_screenshots.project_id = $1
|
|
`,
|
|
project.ID,
|
|
)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
|
|
}
|
|
c.Perf.EndBlock()
|
|
|
|
c.Perf.StartBlock("SQL", "Fetching project links")
|
|
type projectLinkQuery struct {
|
|
Link models.Link `db:"link"`
|
|
}
|
|
projectLinkResult, err := db.Query(c.Context(), c.Conn, projectLinkQuery{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_links as link
|
|
WHERE
|
|
link.project_id = $1
|
|
ORDER BY link.ordering ASC
|
|
`,
|
|
project.ID,
|
|
)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
|
|
}
|
|
c.Perf.EndBlock()
|
|
|
|
c.Perf.StartBlock("SQL", "Fetch category tree")
|
|
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
|
|
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
|
|
c.Perf.EndBlock()
|
|
|
|
c.Perf.StartBlock("SQL", "Fetching project timeline")
|
|
type postQuery struct {
|
|
Post models.Post `db:"post"`
|
|
Thread models.Thread `db:"thread"`
|
|
Author models.User `db:"author"`
|
|
}
|
|
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
|
|
`
|
|
SELECT $columns
|
|
FROM
|
|
handmade_post AS post
|
|
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
|
|
INNER JOIN auth_user AS author ON author.id = post.author_id
|
|
WHERE
|
|
post.project_id = $1
|
|
ORDER BY post.postdate DESC
|
|
LIMIT $2
|
|
`,
|
|
project.ID,
|
|
maxRecentActivity,
|
|
)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project posts"))
|
|
}
|
|
c.Perf.EndBlock()
|
|
|
|
var projectHomepageData ProjectHomepageData
|
|
|
|
projectHomepageData.BaseData = getBaseData(c)
|
|
if canEdit {
|
|
projectHomepageData.BaseData.Header.EditUrl = hmnurl.BuildProjectEdit(project.Slug, "")
|
|
}
|
|
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
|
|
for _, owner := range owners {
|
|
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
|
|
}
|
|
|
|
if project.Flags == 1 {
|
|
hiddenNotice := templates.Notice{
|
|
Class: "hidden",
|
|
Content: "NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
|
}
|
|
projectHomepageData.Notices = append(projectHomepageData.Notices, hiddenNotice)
|
|
}
|
|
|
|
if project.Lifecycle != models.ProjectLifecycleActive {
|
|
var lifecycleNotice templates.Notice
|
|
switch project.Lifecycle {
|
|
case models.ProjectLifecycleUnapproved:
|
|
lifecycleNotice.Class = "unapproved"
|
|
lifecycleNotice.Content = template.HTML(fmt.Sprintf(
|
|
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
|
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
|
))
|
|
case models.ProjectLifecycleApprovalRequired:
|
|
lifecycleNotice.Class = "unapproved"
|
|
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval. It is only visible to owners and site admins.")
|
|
case models.ProjectLifecycleHiatus:
|
|
lifecycleNotice.Class = "hiatus"
|
|
lifecycleNotice.Content = template.HTML("NOTICE: This project is on hiatus and may not update for a while.")
|
|
case models.ProjectLifecycleDead:
|
|
lifecycleNotice.Class = "dead"
|
|
lifecycleNotice.Content = template.HTML("NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.")
|
|
case models.ProjectLifecycleLTSRequired:
|
|
lifecycleNotice.Class = "lts-reqd"
|
|
lifecycleNotice.Content = template.HTML("NOTICE: This project is awaiting approval for maintenance-mode status.")
|
|
case models.ProjectLifecycleLTS:
|
|
lifecycleNotice.Class = "lts"
|
|
lifecycleNotice.Content = template.HTML("NOTICE: This project has reached a state of completion.")
|
|
}
|
|
projectHomepageData.Notices = append(projectHomepageData.Notices, lifecycleNotice)
|
|
}
|
|
|
|
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
|
projectHomepageData.Screenshots = append(projectHomepageData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
|
|
}
|
|
|
|
for _, link := range projectLinkResult.ToSlice() {
|
|
projectHomepageData.ProjectLinks = append(projectHomepageData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
|
|
}
|
|
|
|
for _, post := range postQueryResult.ToSlice() {
|
|
projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem(
|
|
lineageBuilder,
|
|
&post.(*postQuery).Post,
|
|
&post.(*postQuery).Thread,
|
|
project,
|
|
nil,
|
|
&post.(*postQuery).Author,
|
|
c.Theme,
|
|
))
|
|
}
|
|
|
|
var res ResponseData
|
|
err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf)
|
|
if err != nil {
|
|
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
|
|
}
|
|
return res
|
|
}
|