hmn/src/website/projects.go

446 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 subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
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,
&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
}