diff --git a/local/resetdb.sh b/local/resetdb.sh
index 7f1e7401..1cc03996 100755
--- a/local/resetdb.sh
+++ b/local/resetdb.sh
@@ -9,8 +9,8 @@ set -euxo pipefail
# TODO(opensource): We should adapt Asaf's seedfile command and then delete this.
THIS_PATH=$(pwd)
-#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
- BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
+BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
+# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
pushd $BETA_PATH
docker-compose down -v
@@ -19,4 +19,4 @@ pushd $BETA_PATH
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
popd
-go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23
+go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06
diff --git a/src/db/db.go b/src/db/db.go
index 32996e2f..01c20c96 100644
--- a/src/db/db.go
+++ b/src/db/db.go
@@ -60,6 +60,12 @@ type ConnOrTx interface {
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
+
+ // Both raw database connections and transactions in pgx can begin/commit
+ // transactions. For database connections it does the obvious thing; for
+ // transactions it creates a "pseudo-nested transaction" but conceptually
+ // works the same. See the documentation of pgx.Tx.Begin.
+ Begin(ctx context.Context) (pgx.Tx, error)
}
var connInfo = pgtype.NewConnInfo()
diff --git a/src/migration/migrationTemplate.txt b/src/migration/migrationTemplate.txt
index 7656e2fd..53b4e355 100644
--- a/src/migration/migrationTemplate.txt
+++ b/src/migration/migrationTemplate.txt
@@ -5,7 +5,6 @@ import (
"time"
"git.handmade.network/hmn/hmn/src/migration/types"
- "git.handmade.network/hmn/hmn/src/oops"
"github.com/jackc/pgx/v4"
)
diff --git a/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go
new file mode 100644
index 00000000..5bc0a552
--- /dev/null
+++ b/src/migration/migrations/2021-11-06T033835Z_CleanUpProjects.go
@@ -0,0 +1,68 @@
+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(CleanUpProjects{})
+}
+
+type CleanUpProjects struct{}
+
+func (m CleanUpProjects) Version() types.MigrationVersion {
+ return types.MigrationVersion(time.Date(2021, 11, 6, 3, 38, 35, 0, time.UTC))
+}
+
+func (m CleanUpProjects) Name() string {
+ return "CleanUpProjects"
+}
+
+func (m CleanUpProjects) Description() string {
+ return "Clean up projects with data violating our new constraints"
+}
+
+func (m CleanUpProjects) Up(ctx context.Context, tx pgx.Tx) error {
+ var err error
+
+ _, err = tx.Exec(ctx, `
+ DELETE FROM handmade_project WHERE id IN (91, 92);
+ DELETE FROM handmade_communicationchoicelist
+ WHERE project_id IN (91, 92);
+ DELETE FROM handmade_project_languages
+ WHERE project_id IN (91, 92);
+ DELETE FROM handmade_project_screenshots
+ WHERE project_id IN (91, 92);
+
+ UPDATE handmade_project
+ SET slug = 'hmh-notes'
+ WHERE slug = 'hmh_notes';
+ `)
+ if err != nil {
+ return oops.New(err, "failed to patch up project slugs")
+ }
+
+ return nil
+}
+
+func (m CleanUpProjects) Down(ctx context.Context, tx pgx.Tx) error {
+ var err error
+
+ _, err = tx.Exec(ctx, `
+ -- Don't bother restoring those old projects
+
+ UPDATE handmade_project
+ SET slug = 'hmh_notes'
+ WHERE slug = 'hmh-notes';
+ `)
+ if err != nil {
+ return oops.New(err, "failed to restore project slugs")
+ }
+
+ return nil
+}
diff --git a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go
new file mode 100644
index 00000000..b07b286d
--- /dev/null
+++ b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go
@@ -0,0 +1,81 @@
+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(PersonalProjects{})
+}
+
+type PersonalProjects struct{}
+
+func (m PersonalProjects) Version() types.MigrationVersion {
+ return types.MigrationVersion(time.Date(2021, 11, 6, 3, 39, 30, 0, time.UTC))
+}
+
+func (m PersonalProjects) Name() string {
+ return "PersonalProjects"
+}
+
+func (m PersonalProjects) Description() string {
+ return "Add data model for personal projects / tags"
+}
+
+func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
+ _, err := tx.Exec(ctx, `
+ ALTER TABLE handmade_project
+ DROP featurevotes,
+ DROP parent_id,
+ DROP quota,
+ DROP quota_used,
+ DROP standalone,
+ ALTER flags TYPE BOOLEAN USING flags > 0;
+
+ ALTER TABLE handmade_project RENAME flags TO hidden;
+ `)
+ if err != nil {
+ return oops.New(err, "failed to clean up existing fields")
+ }
+
+ _, err = tx.Exec(ctx, `
+ ALTER TABLE handmade_project
+ ADD personal BOOLEAN NOT NULL DEFAULT TRUE,
+ ADD tag VARCHAR(20) NOT NULL DEFAULT '';
+ `)
+ if err != nil {
+ return oops.New(err, "failed to add new fields")
+ }
+
+ _, err = tx.Exec(ctx, `
+ ALTER TABLE handmade_project
+ ADD CONSTRAINT slug_syntax CHECK (
+ slug ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
+ ),
+ ADD CONSTRAINT tag_syntax CHECK (
+ tag ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
+ )
+ `)
+ if err != nil {
+ return oops.New(err, "failed to add check constraints")
+ }
+
+ _, err = tx.Exec(ctx, `
+ UPDATE handmade_project
+ SET personal = FALSE;
+ `)
+ if err != nil {
+ return oops.New(err, "failed to make existing projects official")
+ }
+
+ return nil
+}
+
+func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
+ panic("Implement me")
+}
diff --git a/src/models/project.go b/src/models/project.go
index 5900312a..9aee904f 100644
--- a/src/models/project.go
+++ b/src/models/project.go
@@ -15,7 +15,7 @@ var ProjectType = reflect.TypeOf(Project{})
type ProjectLifecycle int
const (
- ProjectLifecycleUnapproved = iota
+ ProjectLifecycleUnapproved ProjectLifecycle = iota
ProjectLifecycleApprovalRequired
ProjectLifecycleActive
ProjectLifecycleHiatus
@@ -41,6 +41,7 @@ type Project struct {
Slug string `db:"slug"`
Name string `db:"name"`
+ Tag string `db:"tag"`
Blurb string `db:"blurb"`
Description string `db:"description"`
ParsedDescription string `db:"descparsed"`
@@ -53,7 +54,8 @@ type Project struct {
LogoLight string `db:"logolight"`
LogoDark string `db:"logodark"`
- Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible.
+ Personal bool `db:"personal"`
+ Hidden bool `db:"hidden"`
Featured bool `db:"featured"`
DateApproved time.Time `db:"date_approved"`
AllLastUpdated time.Time `db:"all_last_updated"`
diff --git a/src/templates/src/project_index.html b/src/templates/src/project_index.html
index 69d7f027..5962f0ea 100644
--- a/src/templates/src/project_index.html
+++ b/src/templates/src/project_index.html
@@ -47,8 +47,12 @@
-
4 RSS Feed – New Projects
- {{ template "pagination.html" .Pagination }}
+
+
+ {{ template "pagination.html" .Pagination }}
+
@@ -66,38 +70,10 @@
- {{ if not .UserPendingProject }}
-
-
Project submissions are closed
-
- We are reworking the way we approach projects on the network. In the meantime feel free to share your work on the forums or on our Discord.
-
-
- {{ else }}
-
-
Project pending
-
Thanks for considering us as a home for
{{ .UserPendingProject.Name }}!
-
- {{ if .UserPendingProjectUnderReview }}
-
We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.
- {{ else }}
-
When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.
- {{ end }}
-
- {{ end }}
- {{ if .UserApprovedProjects }}
-
- {{ if .UserPendingProject }}
-
Your other projects
- {{ else }}
-
Your projects
- {{ end }}
- {{ range .UserApprovedProjects }}
-
- {{ template "project_card.html" projectcarddata . "" }}
-
- {{ end }}
-
+
Personal Projects
+
Many community members have projects of their own that are currently works in progress. Here's a few:
+ {{ range .PersonalProjects }}
+
{{ end }}
diff --git a/src/website/blogs.go b/src/website/blogs.go
index e535fd7f..9e318683 100644
--- a/src/website/blogs.go
+++ b/src/website/blogs.go
@@ -75,7 +75,7 @@ func BlogIndex(c *RequestContext) ResponseData {
canCreate := false
if c.CurrentUser != nil {
isProjectOwner := false
- owners, err := FetchProjectOwners(c, c.CurrentProject.ID)
+ owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners"))
}
diff --git a/src/website/feed.go b/src/website/feed.go
index bb74862f..5d6f1663 100644
--- a/src/website/feed.go
+++ b/src/website/feed.go
@@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData {
handmade_project AS project
WHERE
project.lifecycle = ANY($1)
- AND project.flags = 0
+ AND NOT project.hidden
ORDER BY date_approved DESC
LIMIT $2
`,
diff --git a/src/website/project_helper.go b/src/website/project_helper.go
index 83ba7435..f5fed7aa 100644
--- a/src/website/project_helper.go
+++ b/src/website/project_helper.go
@@ -1,17 +1,173 @@
package website
import (
+ "context"
+
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
)
+type ProjectTypeQuery int
+
+const (
+ PersonalProjects ProjectTypeQuery = 1 << iota
+ OfficialProjects
+)
+
+type ProjectsQuery struct {
+ Lifecycles []models.ProjectLifecycle
+ Types ProjectTypeQuery // bitfield
+
+ // Ignored when using CountProjects
+ Limit, Offset int // if empty, no pagination
+}
+
+type ProjectAndStuff struct {
+ Project models.Project
+ Owners []*models.User
+}
+
+func FetchProjects(
+ ctx context.Context,
+ dbConn db.ConnOrTx,
+ currentUser *models.User,
+ q ProjectsQuery,
+) ([]ProjectAndStuff, error) {
+ perf := ExtractPerf(ctx)
+ perf.StartBlock("SQL", "Fetch projects")
+ defer perf.EndBlock()
+
+ var currentUserID *int
+ if currentUser != nil {
+ currentUserID = ¤tUser.ID
+ }
+
+ tx, err := dbConn.Begin(ctx)
+ if err != nil {
+ return nil, oops.New(err, "failed to start transaction")
+ }
+ defer tx.Rollback(ctx)
+
+ // Fetch all valid projects (not yet subject to user permission checks)
+ var qb db.QueryBuilder
+ qb.Add(`
+ SELECT $columns
+ FROM
+ handmade_project AS project
+ WHERE
+ NOT hidden
+ `)
+ if len(q.Lifecycles) > 0 {
+ qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
+ } else {
+ qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
+ }
+ if q.Types != 0 {
+ qb.Add(`AND (FALSE`)
+ if q.Types&PersonalProjects != 0 {
+ qb.Add(`OR project.personal`)
+ }
+ if q.Types&OfficialProjects != 0 {
+ qb.Add(`OR NOT project.personal`)
+ }
+ qb.Add(`)`)
+ }
+ if q.Limit > 0 {
+ qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
+ }
+ itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...)
+ if err != nil {
+ return nil, oops.New(err, "failed to fetch projects")
+ }
+ iprojects := itProjects.ToSlice()
+
+ // Fetch project owners to do permission checks
+ projectIds := make([]int, len(iprojects))
+ for i, iproject := range iprojects {
+ projectIds[i] = iproject.(*models.Project).ID
+ }
+ projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
+ if err != nil {
+ return nil, err
+ }
+
+ var res []ProjectAndStuff
+ for i, iproject := range iprojects {
+ project := iproject.(*models.Project)
+ owners := projectOwners[i].Owners
+
+ /*
+ Per our spec, a user can see a project if:
+ - The project is official
+ - The project is personal and all of the project's owners are approved
+ - The project is personal and the current user is a collaborator (regardless of user status)
+
+ See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c
+ */
+
+ var projectVisible bool
+ if project.Personal {
+ allOwnersApproved := true
+ for _, owner := range owners {
+ if owner.Status != models.UserStatusApproved {
+ allOwnersApproved = false
+ }
+ if currentUserID != nil && *currentUserID == owner.ID {
+ projectVisible = true
+ }
+ }
+ if allOwnersApproved {
+ projectVisible = true
+ }
+ } else {
+ projectVisible = true
+ }
+
+ if projectVisible {
+ res = append(res, ProjectAndStuff{
+ Project: *project,
+ Owners: owners,
+ })
+ }
+ }
+
+ err = tx.Commit(ctx)
+ if err != nil {
+ return nil, oops.New(err, "failed to commit transaction")
+ }
+
+ return res, nil
+}
+
+func CountProjects(
+ ctx context.Context,
+ dbConn db.ConnOrTx,
+ currentUser *models.User,
+ q ProjectsQuery,
+) (int, error) {
+ perf := ExtractPerf(ctx)
+ perf.StartBlock("SQL", "Count projects")
+ defer perf.EndBlock()
+
+ q.Limit = 0
+ q.Offset = 0
+
+ // I'm lazy and there probably won't ever be that many projects.
+ projects, err := FetchProjects(ctx, dbConn, currentUser, q)
+ if err != nil {
+ return 0, oops.New(err, "failed to fetch projects")
+ }
+
+ return len(projects), nil
+}
+
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
if user != nil {
if user.IsStaff {
return true, nil
} else {
- owners, err := FetchProjectOwners(c, projectId)
+ owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId)
if err != nil {
return false, err
}
@@ -25,29 +181,137 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool,
return false, nil
}
-func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) {
- var result []*models.User
- c.Perf.StartBlock("SQL", "Fetching project owners")
- type ownerQuery struct {
- Owner models.User `db:"auth_user"`
+type ProjectOwners struct {
+ ProjectID int
+ Owners []*models.User
+}
+
+// Fetches all owners for multiple projects. Does NOT check permissions on the
+// project IDs, since the assumption is that you will check permissions on the
+// projects themselves before using any of this data.
+//
+// The returned slice will always have one entry for each project ID given, in
+// the same order as they were provided. If there are duplicate project IDs in
+// projectIds, the results will be wrong, so don't do that.
+//
+// This function does not verify that the requested projects do in fact exist.
+func FetchMultipleProjectsOwners(
+ ctx context.Context,
+ dbConn db.ConnOrTx,
+ projectIds []int,
+) ([]ProjectOwners, error) {
+ perf := ExtractPerf(ctx)
+ perf.StartBlock("SQL", "Fetch owners for multiple projects")
+ defer perf.EndBlock()
+
+ tx, err := dbConn.Begin(ctx)
+ if err != nil {
+ return nil, oops.New(err, "failed to start transaction")
}
- ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
+ defer tx.Rollback(ctx)
+
+ // Fetch all user/project pairs for the given projects
+ type userProject struct {
+ UserID int `db:"user_id"`
+ ProjectID int `db:"project_id"`
+ }
+ it, err := db.Query(ctx, tx, userProject{},
`
SELECT $columns
- FROM
- auth_user
- INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id
- WHERE
- uproj.project_id = $1
+ FROM handmade_user_projects
+ WHERE project_id = ANY($1)
`,
- projectId,
+ projectIds,
)
- c.Perf.EndBlock()
if err != nil {
- return result, oops.New(err, "failed to fetch owners for project")
+ return nil, oops.New(err, "failed to fetch project IDs")
}
- for _, ownerRow := range ownerQueryResult.ToSlice() {
- result = append(result, &ownerRow.(*ownerQuery).Owner)
+ iuserprojects := it.ToSlice()
+
+ // Get the unique user IDs from this set and fetch the users from the db
+ var userIds []int
+ for _, iuserproject := range iuserprojects {
+ userProject := iuserproject.(*userProject)
+
+ addUserId := true
+ for _, uid := range userIds {
+ if uid == userProject.UserID {
+ addUserId = false
+ }
+ }
+ if addUserId {
+ userIds = append(userIds, userProject.UserID)
+ }
}
- return result, nil
+ it, err = db.Query(ctx, tx, models.User{},
+ `
+ SELECT $columns
+ FROM auth_user
+ WHERE
+ id = ANY($1)
+ `,
+ userIds,
+ )
+ if err != nil {
+ return nil, oops.New(err, "failed to fetch users for projects")
+ }
+ iusers := it.ToSlice()
+
+ // Build the final result set with real user data
+ res := make([]ProjectOwners, len(projectIds))
+ for i, pid := range projectIds {
+ res[i] = ProjectOwners{ProjectID: pid}
+ }
+ for _, iuserproject := range iuserprojects {
+ userProject := iuserproject.(*userProject)
+
+ // Get a pointer to the existing record in the result
+ var projectOwners *ProjectOwners
+ for i := range res {
+ if res[i].ProjectID == userProject.ProjectID {
+ projectOwners = &res[i]
+ }
+ }
+
+ // Get the full user record we fetched
+ var user *models.User
+ for _, iuser := range iusers {
+ u := iuser.(*models.User)
+ if u.ID == userProject.UserID {
+ user = u
+ }
+ }
+ if user == nil {
+ panic("we apparently failed to fetch a project's owner")
+ }
+
+ // Slam 'em together
+ projectOwners.Owners = append(projectOwners.Owners, user)
+ }
+
+ err = tx.Commit(ctx)
+ if err != nil {
+ return nil, oops.New(err, "failed to commit transaction")
+ }
+
+ return res, nil
+}
+
+// Fetches project owners for a single project. It is subject to all the same
+// restrictions as FetchMultipleProjectsOwners.
+func FetchProjectOwners(
+ ctx context.Context,
+ dbConn db.ConnOrTx,
+ projectId int,
+) ([]*models.User, error) {
+ perf := ExtractPerf(ctx)
+ perf.StartBlock("SQL", "Fetch owners for project")
+ defer perf.EndBlock()
+
+ projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId})
+ if err != nil {
+ return nil, err
+ }
+
+ return projectOwners[0].Owners, nil
}
diff --git a/src/website/projects.go b/src/website/projects.go
index 7daf7007..8f533f25 100644
--- a/src/website/projects.go
+++ b/src/website/projects.go
@@ -6,7 +6,6 @@ import (
"math"
"math/rand"
"net/http"
- "strconv"
"strings"
"time"
@@ -24,10 +23,7 @@ type ProjectTemplateData struct {
Pagination templates.Pagination
CarouselProjects []templates.Project
Projects []templates.Project
-
- UserPendingProjectUnderReview bool
- UserPendingProject *templates.Project
- UserApprovedProjects []templates.Project
+ PersonalProjects []templates.Project
ProjectAtomFeedUrl string
WIPForumUrl string
@@ -36,47 +32,19 @@ type ProjectTemplateData struct {
func ProjectIndex(c *RequestContext) ResponseData {
const projectsPerPage = 20
const maxCarouselProjects = 10
+ const maxPersonalProjects = 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,
- )
+ officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
+ Types: OfficialProjects,
+ })
if err != nil {
return c.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)
+ numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage))
+ page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage)
+ if !ok {
+ return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
}
pagination := templates.Pagination{
@@ -89,63 +57,22 @@ func ProjectIndex(c *RequestContext) ResponseData {
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_user_projects AS uproj ON uproj.project_id = project.id
- WHERE
- uproj.user_id = $1
- `,
- c.CurrentUser.ID,
- )
- if err != nil {
- return c.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" {
+ for _, p := range officialProjects {
+ templateProject := templates.ProjectToTemplate(&p.Project, c.Theme)
+ if p.Project.Slug == "hero" {
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
handmadeHero = &templateProject
continue
}
- if project.Featured {
+ if p.Project.Featured {
featuredProjects = append(featuredProjects, templateProject)
- } else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
+ } else if now.Sub(p.Project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
recentProjects = append(recentProjects, templateProject)
} else {
restProjects = append(restProjects, templateProject)
@@ -178,6 +105,28 @@ func ProjectIndex(c *RequestContext) ResponseData {
}
c.Perf.EndBlock()
+ // Fetch and highlight a random selection of personal projects
+ var personalProjects []templates.Project
+ {
+ projects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
+ Types: PersonalProjects,
+ })
+ if err != nil {
+ return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal projects"))
+ }
+
+ randSeed := now.YearDay()
+ random := rand.New(rand.NewSource(int64(randSeed)))
+ random.Shuffle(len(projects), func(i, j int) { projects[i], projects[j] = projects[j], projects[i] })
+
+ for i, p := range projects {
+ if i >= maxPersonalProjects {
+ break
+ }
+ personalProjects = append(personalProjects, templates.ProjectToTemplate(&p.Project, c.Theme))
+ }
+ }
+
baseData := getBaseDataAutocrumb(c, "Projects")
var res ResponseData
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
@@ -186,10 +135,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
Pagination: pagination,
CarouselProjects: carouselProjects,
Projects: pageProjects,
-
- UserPendingProjectUnderReview: userPendingProjectUnderReview,
- UserPendingProject: userPendingProject,
- UserApprovedProjects: userApprovedProjects,
+ PersonalProjects: personalProjects,
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1),
@@ -253,7 +199,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
return FourOhFour(c)
}
- owners, err := FetchProjectOwners(c, project.ID)
+ owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
@@ -275,7 +221,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
}
}
if !canView {
- if project.Flags == 0 {
+ if !project.Hidden {
for _, lc := range models.VisibleProjectLifecycles {
if project.Lifecycle == lc {
canView = true
@@ -377,7 +323,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
}
- if project.Flags == 1 {
+ if project.Hidden {
projectHomepageData.BaseData.AddImmediateNotice(
"hidden",
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
diff --git a/src/website/threads_and_posts_helper.go b/src/website/threads_and_posts_helper.go
index b1d32edc..9cac64b8 100644
--- a/src/website/threads_and_posts_helper.go
+++ b/src/website/threads_and_posts_helper.go
@@ -88,7 +88,7 @@ func FetchThreads(
WHERE
NOT thread.deleted
AND ( -- project has valid lifecycle
- project.flags = 0 AND project.lifecycle = ANY($?)
+ NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
@@ -219,7 +219,7 @@ func CountThreads(
WHERE
NOT thread.deleted
AND ( -- project has valid lifecycle
- project.flags = 0 AND project.lifecycle = ANY($?)
+ NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
@@ -343,7 +343,7 @@ func FetchPosts(
NOT thread.deleted
AND NOT post.deleted
AND ( -- project has valid lifecycle
- project.flags = 0 AND project.lifecycle = ANY($?)
+ NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
@@ -543,7 +543,7 @@ func CountPosts(
NOT thread.deleted
AND NOT post.deleted
AND ( -- project has valid lifecycle
- project.flags = 0 AND project.lifecycle = ANY($?)
+ NOT project.hidden AND project.lifecycle = ANY($?)
OR project.id = $?
)
`,
diff --git a/src/website/user.go b/src/website/user.go
index 92fba61b..e1f79b7c 100644
--- a/src/website/user.go
+++ b/src/website/user.go
@@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData {
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
WHERE
uproj.user_id = $1
- AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
+ AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3)))
`,
profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),