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 submit it for approval 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
}