2021-06-06 23:48:43 +00:00
package website
import (
2021-07-08 07:40:30 +00:00
"errors"
"fmt"
"html/template"
2021-06-06 23:48:43 +00:00
"math"
"math/rand"
"net/http"
"strconv"
2021-07-08 07:40:30 +00:00
"strings"
2021-06-06 23:48:43 +00:00
"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
2021-08-03 03:27:59 +00:00
INNER JOIN handmade_user_projects AS uproj ON uproj . project_id = project . id
2021-06-06 23:48:43 +00:00
WHERE
2021-08-03 03:27:59 +00:00
uproj . user_id = $ 1
2021-06-06 23:48:43 +00:00
` ,
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
2021-07-17 15:19:17 +00:00
res . MustWriteTemplate ( "project_index.html" , ProjectTemplateData {
2021-06-06 23:48:43 +00:00
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
}
2021-07-08 07:40:30 +00:00
type ProjectHomepageData struct {
templates . BaseData
Project templates . Project
Owners [ ] templates . User
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 )
}
2021-07-23 03:09:46 +00:00
owners , err := FetchProjectOwners ( c , project . ID )
2021-07-08 07:40:30 +00:00
if err != nil {
2021-07-23 03:09:46 +00:00
return ErrorResponse ( http . StatusInternalServerError , err )
2021-07-08 07:40:30 +00:00
}
canView := false
canEdit := false
if c . CurrentUser != nil {
2021-07-23 03:09:46 +00:00
if c . CurrentUser . IsStaff {
2021-07-08 07:40:30 +00:00
canView = true
canEdit = true
} else {
2021-07-23 03:09:46 +00:00
for _ , owner := range owners {
if owner . ID == c . CurrentUser . ID {
2021-07-08 07:40:30 +00:00
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 ( )
2021-07-30 03:40:47 +00:00
c . Perf . StartBlock ( "SQL" , "Fetch subforum tree" )
subforumTree := models . GetFullSubforumTree ( c . Context ( ) , c . Conn )
lineageBuilder := models . MakeSubforumLineageBuilder ( subforumTree )
2021-07-08 07:40:30 +00:00
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 )
2021-07-23 03:09:46 +00:00
for _ , owner := range owners {
projectHomepageData . Owners = append ( projectHomepageData . Owners , templates . UserToTemplate ( owner , c . Theme ) )
2021-07-08 07:40:30 +00:00
}
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." ,
}
2021-08-08 20:05:52 +00:00
projectHomepageData . BaseData . Notices = append ( projectHomepageData . BaseData . Notices , hiddenNotice )
2021-07-08 07:40:30 +00:00
}
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." )
}
2021-08-08 20:05:52 +00:00
projectHomepageData . BaseData . Notices = append ( projectHomepageData . BaseData . Notices , lifecycleNotice )
2021-07-08 07:40:30 +00:00
}
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
}