Personal project creation
This commit is contained in:
parent
03c82c9d1a
commit
950e84d53a
|
@ -48,6 +48,7 @@ func addCreateProjectCommand(projectCommand *cobra.Command) {
|
||||||
|
|
||||||
p, err := website.FetchProject(ctx, tx, nil, models.HMNProjectID, website.ProjectsQuery{
|
p, err := website.FetchProject(ctx, tx, nil, models.HMNProjectID, website.ProjectsQuery{
|
||||||
IncludeHidden: true,
|
IncludeHidden: true,
|
||||||
|
Lifecycles: models.AllProjectLifecycles,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
@ -170,6 +171,7 @@ func addProjectTagCommand(projectCommand *cobra.Command) {
|
||||||
|
|
||||||
p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{
|
p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{
|
||||||
IncludeHidden: true,
|
IncludeHidden: true,
|
||||||
|
Lifecycles: models.AllProjectLifecycles,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|
26
src/db/db.go
26
src/db/db.go
|
@ -285,10 +285,36 @@ func getColumnNamesAndPaths(destType reflect.Type, pathSoFar []int, prefix strin
|
||||||
return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix)
|
return nil, nil, oops.New(nil, "can only get column names and paths from a struct, got type '%v' (at prefix '%v')", destType.Name(), prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AnonPrefix struct {
|
||||||
|
Path []int
|
||||||
|
Prefix string
|
||||||
|
}
|
||||||
|
var anonPrefixes []AnonPrefix
|
||||||
|
|
||||||
for _, field := range reflect.VisibleFields(destType) {
|
for _, field := range reflect.VisibleFields(destType) {
|
||||||
path := append(pathSoFar, field.Index...)
|
path := append(pathSoFar, field.Index...)
|
||||||
|
|
||||||
if columnName := field.Tag.Get("db"); columnName != "" {
|
if columnName := field.Tag.Get("db"); columnName != "" {
|
||||||
|
if field.Anonymous {
|
||||||
|
anonPrefixes = append(anonPrefixes, AnonPrefix{Path: field.Index, Prefix: columnName})
|
||||||
|
continue
|
||||||
|
} else {
|
||||||
|
for _, anonPrefix := range anonPrefixes {
|
||||||
|
if len(field.Index) > len(anonPrefix.Path) {
|
||||||
|
equal := true
|
||||||
|
for i := range anonPrefix.Path {
|
||||||
|
if anonPrefix.Path[i] != field.Index[i] {
|
||||||
|
equal = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if equal {
|
||||||
|
columnName = anonPrefix.Prefix + "." + columnName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fieldType := field.Type
|
fieldType := field.Type
|
||||||
if fieldType.Kind() == reflect.Ptr {
|
if fieldType.Kind() == reflect.Ptr {
|
||||||
fieldType = fieldType.Elem()
|
fieldType = fieldType.Elem()
|
||||||
|
|
|
@ -0,0 +1,49 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(AddLogoAssetsToProjects{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddLogoAssetsToProjects struct{}
|
||||||
|
|
||||||
|
func (m AddLogoAssetsToProjects) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 11, 28, 16, 23, 0, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddLogoAssetsToProjects) Name() string {
|
||||||
|
return "AddLogoAssetsToProjects"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddLogoAssetsToProjects) Description() string {
|
||||||
|
return "Add optional asset references for project logos"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddLogoAssetsToProjects) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
ADD COLUMN logodark_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL,
|
||||||
|
ADD COLUMN logolight_asset_id UUID REFERENCES handmade_asset (id) ON DELETE SET NULL;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddLogoAssetsToProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
DROP COLUMN logodark_asset_id,
|
||||||
|
DROP COLUMN logolight_asset_id;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
|
@ -0,0 +1,73 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(AddDefaultsToProjects{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type AddDefaultsToProjects struct{}
|
||||||
|
|
||||||
|
func (m AddDefaultsToProjects) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 11, 28, 17, 2, 18, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddDefaultsToProjects) Name() string {
|
||||||
|
return "AddDefaultsToProjects"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddDefaultsToProjects) Description() string {
|
||||||
|
return "Add default values to many project columns"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddDefaultsToProjects) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
ALTER COLUMN slug SET DEFAULT '',
|
||||||
|
ALTER COLUMN color_1 SET DEFAULT 'ab4c47',
|
||||||
|
ALTER COLUMN color_2 SET DEFAULT 'a5467d',
|
||||||
|
ALTER COLUMN featured SET DEFAULT FALSE,
|
||||||
|
ALTER COLUMN hidden SET DEFAULT FALSE,
|
||||||
|
ALTER COLUMN blog_enabled SET DEFAULT FALSE,
|
||||||
|
ALTER COLUMN forum_enabled SET DEFAULT FALSE,
|
||||||
|
ALTER COLUMN all_last_updated SET DEFAULT 'epoch',
|
||||||
|
ALTER COLUMN annotation_last_updated SET DEFAULT 'epoch',
|
||||||
|
ALTER COLUMN blog_last_updated SET DEFAULT 'epoch',
|
||||||
|
ALTER COLUMN forum_last_updated SET DEFAULT 'epoch',
|
||||||
|
ALTER COLUMN date_approved SET DEFAULT 'epoch',
|
||||||
|
ALTER COLUMN bg_flags SET DEFAULT 0,
|
||||||
|
ALTER COLUMN library_enabled SET DEFAULT FALSE;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m AddDefaultsToProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
_, err := tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
ALTER COLUMN slug DROP DEFAULT,
|
||||||
|
ALTER COLUMN color_1 DROP DEFAULT,
|
||||||
|
ALTER COLUMN color_2 DROP DEFAULT,
|
||||||
|
ALTER COLUMN featured DROP DEFAULT,
|
||||||
|
ALTER COLUMN hidden DROP DEFAULT,
|
||||||
|
ALTER COLUMN blog_enabled DROP DEFAULT,
|
||||||
|
ALTER COLUMN forum_enabled DROP DEFAULT,
|
||||||
|
ALTER COLUMN all_last_updated DROP DEFAULT,
|
||||||
|
ALTER COLUMN annotation_last_updated DROP DEFAULT,
|
||||||
|
ALTER COLUMN blog_last_updated DROP DEFAULT,
|
||||||
|
ALTER COLUMN forum_last_updated DROP DEFAULT,
|
||||||
|
ALTER COLUMN date_approved DROP DEFAULT,
|
||||||
|
ALTER COLUMN bg_flags DROP DEFAULT,
|
||||||
|
ALTER COLUMN library_enabled DROP DEFAULT;
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
|
@ -26,6 +26,16 @@ const (
|
||||||
ProjectLifecycleLTS
|
ProjectLifecycleLTS
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var AllProjectLifecycles = []ProjectLifecycle{
|
||||||
|
ProjectLifecycleUnapproved,
|
||||||
|
ProjectLifecycleApprovalRequired,
|
||||||
|
ProjectLifecycleActive,
|
||||||
|
ProjectLifecycleHiatus,
|
||||||
|
ProjectLifecycleDead,
|
||||||
|
ProjectLifecycleLTSRequired,
|
||||||
|
ProjectLifecycleLTS,
|
||||||
|
}
|
||||||
|
|
||||||
// NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must have flags = 0.
|
// NOTE(asaf): Just checking the lifecycle is not sufficient. Visible projects also must have flags = 0.
|
||||||
var VisibleProjectLifecycles = []ProjectLifecycle{
|
var VisibleProjectLifecycles = []ProjectLifecycle{
|
||||||
ProjectLifecycleActive,
|
ProjectLifecycleActive,
|
||||||
|
@ -70,6 +80,12 @@ type Project struct {
|
||||||
LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db
|
LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectWithLogos struct {
|
||||||
|
Project `db:"project"`
|
||||||
|
LogoLightAsset *Asset `db:"logolight_asset"`
|
||||||
|
LogoDarkAsset *Asset `db:"logodark_asset"`
|
||||||
|
}
|
||||||
|
|
||||||
func (p *Project) IsHMN() bool {
|
func (p *Project) IsHMN() bool {
|
||||||
return p.ID == HMNProjectID
|
return p.ID == HMNProjectID
|
||||||
}
|
}
|
||||||
|
|
|
@ -59,11 +59,23 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
|
||||||
models.ProjectLifecycleLTS: "Complete",
|
models.ProjectLifecycleLTS: "Complete",
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectToTemplate(p *models.Project, url string, theme string) Project {
|
func ProjectLogoUrl(p *models.ProjectWithLogos, theme string) string {
|
||||||
logo := p.LogoLight
|
|
||||||
if theme == "dark" {
|
if theme == "dark" {
|
||||||
logo = p.LogoDark
|
if p.LogoDarkAsset != nil {
|
||||||
|
return hmnurl.BuildS3Asset(p.LogoDarkAsset.S3Key)
|
||||||
|
} else {
|
||||||
|
return hmnurl.BuildUserFile(p.Project.LogoDark)
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
if p.LogoLightAsset != nil {
|
||||||
|
return hmnurl.BuildS3Asset(p.LogoLightAsset.S3Key)
|
||||||
|
} else {
|
||||||
|
return hmnurl.BuildUserFile(p.Project.LogoLight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Project {
|
||||||
return Project{
|
return Project{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Subdomain: p.Subdomain(),
|
Subdomain: p.Subdomain(),
|
||||||
|
@ -73,7 +85,7 @@ func ProjectToTemplate(p *models.Project, url string, theme string) Project {
|
||||||
Blurb: p.Blurb,
|
Blurb: p.Blurb,
|
||||||
ParsedDescription: template.HTML(p.ParsedDescription),
|
ParsedDescription: template.HTML(p.ParsedDescription),
|
||||||
|
|
||||||
Logo: hmnurl.BuildUserFile(logo),
|
Logo: ProjectLogoUrl(p, theme),
|
||||||
|
|
||||||
LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle],
|
LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle],
|
||||||
LifecycleString: LifecycleBadgeStrings[p.Lifecycle],
|
LifecycleString: LifecycleBadgeStrings[p.Lifecycle],
|
||||||
|
|
|
@ -68,7 +68,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ if .User.IsStaff }}
|
{{ if and .Editing .User.IsStaff }}
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div class="pt-input-ns">Admin settings</div>
|
<div class="pt-input-ns">Admin settings</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -109,7 +109,7 @@
|
||||||
<div class="edit-form-row">
|
<div class="edit-form-row">
|
||||||
<div class="pt-input-ns">Short description:</div>
|
<div class="pt-input-ns">Short description:</div>
|
||||||
<div>
|
<div>
|
||||||
<textarea maxlength="140" name="shortdesc">
|
<textarea required maxlength="140" name="shortdesc">
|
||||||
{{- .ProjectSettings.Blurb -}}
|
{{- .ProjectSettings.Blurb -}}
|
||||||
</textarea>
|
</textarea>
|
||||||
<div class="c--dim f7">Plaintext only. No links or markdown.</div>
|
<div class="c--dim f7">Plaintext only. No links or markdown.</div>
|
||||||
|
|
|
@ -68,7 +68,11 @@
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
<div class="description ph3 ph0-ns">
|
<div class="description ph3 ph0-ns">
|
||||||
|
{{ if .Project.ParsedDescription }}
|
||||||
{{ .Project.ParsedDescription }}
|
{{ .Project.ParsedDescription }}
|
||||||
|
{{ else }}
|
||||||
|
{{ .Project.Blurb }}
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ with .RecentActivity }}
|
{{ with .RecentActivity }}
|
||||||
<div class="content-block timeline-container ph3 ph0-ns mv4">
|
<div class="content-block timeline-container ph3 ph0-ns mv4">
|
||||||
|
|
|
@ -14,7 +14,7 @@ func getBaseDataAutocrumb(c *RequestContext, title string) templates.BaseData {
|
||||||
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
|
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
|
||||||
// If you pass nil, no breadcrumbs will be created.
|
// If you pass nil, no breadcrumbs will be created.
|
||||||
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
|
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
|
||||||
var project models.Project
|
var project models.ProjectWithLogos
|
||||||
if c.CurrentProject != nil {
|
if c.CurrentProject != nil {
|
||||||
project = *c.CurrentProject
|
project = *c.CurrentProject
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
|
|
||||||
ReportIssueMailto: "team@handmade.network",
|
ReportIssueMailto: "team@handmade.network",
|
||||||
|
|
||||||
OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
|
OpenGraphItems: buildDefaultOpenGraphItems(&project.Project, title),
|
||||||
|
|
||||||
IsProjectPage: !project.IsHMN(),
|
IsProjectPage: !project.IsHMN(),
|
||||||
Header: templates.Header{
|
Header: templates.Header{
|
||||||
|
|
|
@ -9,7 +9,6 @@ import (
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
@ -153,68 +152,30 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
feedData.FeedUrl = hmnurl.BuildProjectIndex(1)
|
feedData.FeedUrl = hmnurl.BuildProjectIndex(1)
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetching projects")
|
c.Perf.StartBlock("SQL", "Fetching projects")
|
||||||
type projectResult struct {
|
|
||||||
Project models.Project `db:"project"`
|
|
||||||
}
|
|
||||||
_, hasAll := c.Req.URL.Query()["all"]
|
_, hasAll := c.Req.URL.Query()["all"]
|
||||||
if hasAll {
|
if hasAll {
|
||||||
itemsPerFeed = 100000
|
itemsPerFeed = 100000
|
||||||
}
|
}
|
||||||
projects, err := db.Query(c.Context(), c.Conn, projectResult{},
|
projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, nil, ProjectsQuery{
|
||||||
`
|
Lifecycles: models.VisibleProjectLifecycles,
|
||||||
SELECT $columns
|
Limit: itemsPerFeed,
|
||||||
FROM
|
Types: OfficialProjects,
|
||||||
handmade_project AS project
|
OrderBy: "date_approved DESC",
|
||||||
WHERE
|
})
|
||||||
project.lifecycle = ANY($1)
|
|
||||||
AND NOT project.hidden
|
|
||||||
ORDER BY date_approved DESC
|
|
||||||
LIMIT $2
|
|
||||||
`,
|
|
||||||
models.VisibleProjectLifecycles,
|
|
||||||
itemsPerFeed,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects"))
|
||||||
}
|
}
|
||||||
var projectIds []int
|
for _, p := range projectsAndStuff {
|
||||||
projectMap := make(map[int]int) // map[project id]index in slice
|
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme)
|
||||||
for _, p := range projects.ToSlice() {
|
|
||||||
project := p.(*projectResult).Project
|
|
||||||
templateProject := templates.ProjectToTemplate(&project, UrlContextForProject(&project).BuildHomepage(), c.Theme)
|
|
||||||
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
|
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
|
||||||
|
for _, owner := range p.Owners {
|
||||||
|
templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner, ""))
|
||||||
|
}
|
||||||
|
|
||||||
projectIds = append(projectIds, project.ID)
|
|
||||||
feedData.Projects = append(feedData.Projects, templateProject)
|
feedData.Projects = append(feedData.Projects, templateProject)
|
||||||
projectMap[project.ID] = len(feedData.Projects) - 1
|
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetching project owners")
|
|
||||||
type ownerResult struct {
|
|
||||||
User models.User `db:"auth_user"`
|
|
||||||
ProjectID int `db:"uproj.project_id"`
|
|
||||||
}
|
|
||||||
owners, err := db.Query(c.Context(), c.Conn, ownerResult{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_user_projects AS uproj
|
|
||||||
JOIN auth_user ON uproj.user_id = auth_user.id
|
|
||||||
WHERE
|
|
||||||
uproj.project_id = ANY($1)
|
|
||||||
`,
|
|
||||||
projectIds,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects owners"))
|
|
||||||
}
|
|
||||||
for _, res := range owners.ToSlice() {
|
|
||||||
owner := res.(*ownerResult)
|
|
||||||
templateProject := &feedData.Projects[projectMap[owner.ProjectID]]
|
|
||||||
templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(&owner.User, ""))
|
|
||||||
}
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
updated := time.Now()
|
updated := time.Now()
|
||||||
if len(feedData.Projects) > 0 {
|
if len(feedData.Projects) > 0 {
|
||||||
updated = feedData.Projects[0].DateApproved
|
updated = feedData.Projects[0].DateApproved
|
||||||
|
|
|
@ -865,7 +865,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
|
if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums {
|
||||||
sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums)
|
sfId, valid := validateSubforums(lineageBuilder, &c.CurrentProject.Project, subforums)
|
||||||
if !valid {
|
if !valid {
|
||||||
return commonForumData{}, false
|
return commonForumData{}, false
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,6 +2,7 @@ package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
|
|
||||||
|
@ -22,17 +23,20 @@ type ProjectsQuery struct {
|
||||||
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
||||||
Types ProjectTypeQuery // bitfield
|
Types ProjectTypeQuery // bitfield
|
||||||
IncludeHidden bool
|
IncludeHidden bool
|
||||||
|
AlwaysVisibleToOwnerAndStaff bool
|
||||||
|
|
||||||
// Ignored when using FetchProject
|
// Ignored when using FetchProject
|
||||||
ProjectIDs []int // if empty, all projects
|
ProjectIDs []int // if empty, all projects
|
||||||
Slugs []string // if empty, all projects
|
Slugs []string // if empty, all projects
|
||||||
|
OwnerIDs []int // if empty, all projects
|
||||||
|
|
||||||
// Ignored when using CountProjects
|
// Ignored when using CountProjects
|
||||||
Limit, Offset int // if empty, no pagination
|
Limit, Offset int // if empty, no pagination
|
||||||
|
OrderBy string
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectAndStuff struct {
|
type ProjectAndStuff struct {
|
||||||
Project models.Project
|
Project models.ProjectWithLogos
|
||||||
Owners []*models.User
|
Owners []*models.User
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,26 +63,39 @@ func FetchProjects(
|
||||||
|
|
||||||
// Fetch all valid projects (not yet subject to user permission checks)
|
// Fetch all valid projects (not yet subject to user permission checks)
|
||||||
var qb db.QueryBuilder
|
var qb db.QueryBuilder
|
||||||
|
if len(q.OrderBy) > 0 {
|
||||||
|
qb.Add(`SELECT * FROM (`)
|
||||||
|
}
|
||||||
qb.Add(`
|
qb.Add(`
|
||||||
SELECT $columns
|
SELECT DISTINCT ON (project.id) $columns
|
||||||
FROM
|
FROM
|
||||||
handmade_project AS project
|
handmade_project AS project
|
||||||
|
LEFT JOIN handmade_asset AS logolight_asset ON logolight_asset.id = project.logolight_asset_id
|
||||||
|
LEFT JOIN handmade_asset AS logodark_asset ON logodark_asset.id = project.logodark_asset_id
|
||||||
|
`)
|
||||||
|
if len(q.OwnerIDs) > 0 {
|
||||||
|
qb.Add(`
|
||||||
|
INNER JOIN handmade_user_projects AS owner_filter ON owner_filter.project_id = project.id
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil {
|
||||||
|
qb.Add(`
|
||||||
|
LEFT JOIN handmade_user_projects AS owner_visibility ON owner_visibility.project_id = project.id
|
||||||
|
`)
|
||||||
|
}
|
||||||
|
qb.Add(`
|
||||||
WHERE
|
WHERE
|
||||||
TRUE
|
TRUE
|
||||||
`)
|
`)
|
||||||
if !q.IncludeHidden {
|
// Filters
|
||||||
qb.Add(`AND NOT hidden`)
|
|
||||||
}
|
|
||||||
if len(q.ProjectIDs) > 0 {
|
if len(q.ProjectIDs) > 0 {
|
||||||
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
||||||
}
|
}
|
||||||
if len(q.Slugs) > 0 {
|
if len(q.Slugs) > 0 {
|
||||||
qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs)
|
qb.Add(`AND (project.slug != '' AND project.slug = ANY ($?))`, q.Slugs)
|
||||||
}
|
}
|
||||||
if len(q.Lifecycles) > 0 {
|
if len(q.OwnerIDs) > 0 {
|
||||||
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
|
qb.Add(`AND (owner_filter.user_id = ANY ($?))`, q.OwnerIDs)
|
||||||
} else {
|
|
||||||
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
|
|
||||||
}
|
}
|
||||||
if q.Types != 0 {
|
if q.Types != 0 {
|
||||||
qb.Add(`AND (FALSE`)
|
qb.Add(`AND (FALSE`)
|
||||||
|
@ -90,10 +107,31 @@ func FetchProjects(
|
||||||
}
|
}
|
||||||
qb.Add(`)`)
|
qb.Add(`)`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Visibility
|
||||||
|
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil {
|
||||||
|
qb.Add(`AND ($? = TRUE OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID)
|
||||||
|
}
|
||||||
|
if !q.IncludeHidden {
|
||||||
|
qb.Add(`AND 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.AlwaysVisibleToOwnerAndStaff && currentUser != nil {
|
||||||
|
qb.Add(`))`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
if q.Limit > 0 {
|
if q.Limit > 0 {
|
||||||
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||||
}
|
}
|
||||||
itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...)
|
if len(q.OrderBy) > 0 {
|
||||||
|
qb.Add(fmt.Sprintf(`) q ORDER BY %s`, q.OrderBy))
|
||||||
|
}
|
||||||
|
itProjects, err := db.Query(ctx, dbConn, models.ProjectWithLogos{}, qb.String(), qb.Args()...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, oops.New(err, "failed to fetch projects")
|
return nil, oops.New(err, "failed to fetch projects")
|
||||||
}
|
}
|
||||||
|
@ -102,7 +140,7 @@ func FetchProjects(
|
||||||
// Fetch project owners to do permission checks
|
// Fetch project owners to do permission checks
|
||||||
projectIds := make([]int, len(iprojects))
|
projectIds := make([]int, len(iprojects))
|
||||||
for i, iproject := range iprojects {
|
for i, iproject := range iprojects {
|
||||||
projectIds[i] = iproject.(*models.Project).ID
|
projectIds[i] = iproject.(*models.ProjectWithLogos).ID
|
||||||
}
|
}
|
||||||
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
|
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -111,7 +149,7 @@ func FetchProjects(
|
||||||
|
|
||||||
var res []ProjectAndStuff
|
var res []ProjectAndStuff
|
||||||
for i, iproject := range iprojects {
|
for i, iproject := range iprojects {
|
||||||
project := iproject.(*models.Project)
|
project := iproject.(*models.ProjectWithLogos)
|
||||||
owners := projectOwners[i].Owners
|
owners := projectOwners[i].Owners
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
@ -1,19 +1,26 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"io"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/assets"
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"git.handmade.network/hmn/hmn/src/parsing"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
"git.handmade.network/hmn/hmn/src/utils"
|
"git.handmade.network/hmn/hmn/src/utils"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProjectTemplateData struct {
|
type ProjectTemplateData struct {
|
||||||
|
@ -63,7 +70,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
var restProjects []templates.Project
|
var restProjects []templates.Project
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, p := range officialProjects {
|
for _, p := range officialProjects {
|
||||||
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme)
|
||||||
if p.Project.Slug == "hero" {
|
if p.Project.Slug == "hero" {
|
||||||
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
|
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
|
||||||
handmadeHero = &templateProject
|
handmadeHero = &templateProject
|
||||||
|
@ -126,7 +133,7 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
personalProjects = append(personalProjects, templates.ProjectToTemplate(
|
personalProjects = append(personalProjects, templates.ProjectToTemplate(
|
||||||
&p.Project,
|
&p.Project,
|
||||||
UrlContextForProject(&p.Project).BuildHomepage(),
|
UrlContextForProject(&p.Project.Project).BuildHomepage(),
|
||||||
c.Theme,
|
c.Theme,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -291,7 +298,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
case models.ProjectLifecycleDead:
|
case models.ProjectLifecycleDead:
|
||||||
templateData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"dead",
|
"dead",
|
||||||
"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.",
|
"NOTICE: This project is has been marked dead and is only visible to owners and site admins.",
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleLTSRequired:
|
case models.ProjectLifecycleLTSRequired:
|
||||||
templateData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
|
@ -392,7 +399,166 @@ func ProjectNew(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectNewSubmit(c *RequestContext) ResponseData {
|
func ProjectNewSubmit(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
maxBodySize := int64(ProjectLogoMaxFileSize*2 + 1024*1024)
|
||||||
|
c.Req.Body = http.MaxBytesReader(c.Res, c.Req.Body, maxBodySize)
|
||||||
|
err := c.Req.ParseMultipartForm(maxBodySize)
|
||||||
|
if err != nil {
|
||||||
|
// NOTE(asaf): The error for exceeding the max filesize doesn't have a special type, so we can't easily detect it here.
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to parse form"))
|
||||||
|
}
|
||||||
|
|
||||||
|
projectName := strings.TrimSpace(c.Req.Form.Get("project_name"))
|
||||||
|
if len(projectName) == 0 {
|
||||||
|
return RejectRequest(c, "Project name is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
shortDesc := strings.TrimSpace(c.Req.Form.Get("shortdesc"))
|
||||||
|
if len(shortDesc) == 0 {
|
||||||
|
return RejectRequest(c, "Projects must have a short description")
|
||||||
|
}
|
||||||
|
description := c.Req.Form.Get("description")
|
||||||
|
parsedDescription := parsing.ParseMarkdown(description, parsing.ForumRealMarkdown)
|
||||||
|
|
||||||
|
lifecycleStr := c.Req.Form.Get("lifecycle")
|
||||||
|
var lifecycle models.ProjectLifecycle
|
||||||
|
switch lifecycleStr {
|
||||||
|
case "active":
|
||||||
|
lifecycle = models.ProjectLifecycleActive
|
||||||
|
case "hiatus":
|
||||||
|
lifecycle = models.ProjectLifecycleHiatus
|
||||||
|
case "done":
|
||||||
|
lifecycle = models.ProjectLifecycleLTS
|
||||||
|
case "dead":
|
||||||
|
lifecycle = models.ProjectLifecycleDead
|
||||||
|
default:
|
||||||
|
return RejectRequest(c, "Project status is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
hiddenStr := c.Req.Form.Get("hidden")
|
||||||
|
hidden := len(hiddenStr) > 0
|
||||||
|
|
||||||
|
lightLogo, err := GetFormImage(c, "light_logo")
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to read image from form"))
|
||||||
|
}
|
||||||
|
darkLogo, err := GetFormImage(c, "dark_logo")
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to read image from form"))
|
||||||
|
}
|
||||||
|
|
||||||
|
owners := c.Req.Form["owners"]
|
||||||
|
|
||||||
|
tx, err := c.Conn.Begin(c.Context())
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to start db transaction"))
|
||||||
|
}
|
||||||
|
defer tx.Rollback(c.Context())
|
||||||
|
|
||||||
|
var lightLogoUUID *uuid.UUID
|
||||||
|
if lightLogo.Exists {
|
||||||
|
lightLogoAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
|
||||||
|
Content: lightLogo.Content,
|
||||||
|
Filename: lightLogo.Filename,
|
||||||
|
ContentType: lightLogo.Mime,
|
||||||
|
UploaderID: &c.CurrentUser.ID,
|
||||||
|
Width: lightLogo.Width,
|
||||||
|
Height: lightLogo.Height,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save asset"))
|
||||||
|
}
|
||||||
|
lightLogoUUID = &lightLogoAsset.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
var darkLogoUUID *uuid.UUID
|
||||||
|
if darkLogo.Exists {
|
||||||
|
darkLogoAsset, err := assets.Create(c.Context(), tx, assets.CreateInput{
|
||||||
|
Content: darkLogo.Content,
|
||||||
|
Filename: darkLogo.Filename,
|
||||||
|
ContentType: darkLogo.Mime,
|
||||||
|
UploaderID: &c.CurrentUser.ID,
|
||||||
|
Width: darkLogo.Width,
|
||||||
|
Height: darkLogo.Height,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to save asset"))
|
||||||
|
}
|
||||||
|
darkLogoUUID = &darkLogoAsset.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSelf := false
|
||||||
|
selfUsername := strings.ToLower(c.CurrentUser.Username)
|
||||||
|
for i, _ := range owners {
|
||||||
|
owners[i] = strings.ToLower(owners[i])
|
||||||
|
if owners[i] == selfUsername {
|
||||||
|
hasSelf = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasSelf {
|
||||||
|
owners = append(owners, selfUsername)
|
||||||
|
}
|
||||||
|
|
||||||
|
userResult, err := db.Query(c.Context(), c.Conn, models.User{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM auth_user
|
||||||
|
WHERE LOWER(username) = ANY ($1)
|
||||||
|
`,
|
||||||
|
owners,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to query users"))
|
||||||
|
}
|
||||||
|
|
||||||
|
var projectId int
|
||||||
|
err = tx.QueryRow(c.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_project
|
||||||
|
(name, blurb, description, descparsed, logodark_asset_id, logolight_asset_id, lifecycle, hidden, date_created, all_last_updated)
|
||||||
|
VALUES
|
||||||
|
($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
|
||||||
|
RETURNING id
|
||||||
|
`,
|
||||||
|
projectName,
|
||||||
|
shortDesc,
|
||||||
|
description,
|
||||||
|
parsedDescription,
|
||||||
|
darkLogoUUID,
|
||||||
|
lightLogoUUID,
|
||||||
|
lifecycle,
|
||||||
|
hidden,
|
||||||
|
time.Now(), // NOTE(asaf): Using this param twice.
|
||||||
|
).Scan(&projectId)
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert new project"))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, ownerRow := range userResult.ToSlice() {
|
||||||
|
_, err = tx.Exec(c.Context(),
|
||||||
|
`
|
||||||
|
INSERT INTO handmade_user_projects
|
||||||
|
(user_id, project_id)
|
||||||
|
VALUES
|
||||||
|
($1, $2)
|
||||||
|
`,
|
||||||
|
ownerRow.(*models.User).ID,
|
||||||
|
projectId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to insert project owner"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.Commit(c.Context())
|
||||||
|
|
||||||
|
urlContext := &hmnurl.UrlContext{
|
||||||
|
PersonalProject: true,
|
||||||
|
ProjectID: projectId,
|
||||||
|
ProjectName: projectName,
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Redirect(urlContext.BuildHomepage(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectEdit(c *RequestContext) ResponseData {
|
func ProjectEdit(c *RequestContext) ResponseData {
|
||||||
|
@ -412,3 +578,48 @@ func ProjectEdit(c *RequestContext) ResponseData {
|
||||||
func ProjectEditSubmit(c *RequestContext) ResponseData {
|
func ProjectEditSubmit(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type FormImage struct {
|
||||||
|
Exists bool
|
||||||
|
Filename string
|
||||||
|
Mime string
|
||||||
|
Content []byte
|
||||||
|
Width int
|
||||||
|
Height int
|
||||||
|
Size int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE(asaf): This assumes that you already called ParseMultipartForm (which is why there's no size limit here).
|
||||||
|
func GetFormImage(c *RequestContext, fieldName string) (FormImage, error) {
|
||||||
|
var res FormImage
|
||||||
|
res.Exists = false
|
||||||
|
|
||||||
|
img, header, err := c.Req.FormFile(fieldName)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, http.ErrMissingFile) {
|
||||||
|
return res, nil
|
||||||
|
} else {
|
||||||
|
return FormImage{}, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if header != nil {
|
||||||
|
res.Exists = true
|
||||||
|
res.Size = header.Size
|
||||||
|
res.Filename = header.Filename
|
||||||
|
|
||||||
|
res.Content = make([]byte, res.Size)
|
||||||
|
img.Read(res.Content)
|
||||||
|
img.Seek(0, io.SeekStart)
|
||||||
|
|
||||||
|
config, _, err := image.DecodeConfig(img)
|
||||||
|
if err != nil {
|
||||||
|
return FormImage{}, err
|
||||||
|
}
|
||||||
|
res.Width = config.Width
|
||||||
|
res.Height = config.Height
|
||||||
|
res.Mime = http.DetectContentType(res.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
|
@ -160,7 +160,7 @@ type RequestContext struct {
|
||||||
Res http.ResponseWriter
|
Res http.ResponseWriter
|
||||||
|
|
||||||
Conn *pgxpool.Pool
|
Conn *pgxpool.Pool
|
||||||
CurrentProject *models.Project
|
CurrentProject *models.ProjectWithLogos
|
||||||
CurrentUser *models.User
|
CurrentUser *models.User
|
||||||
CurrentSession *models.Session
|
CurrentSession *models.Session
|
||||||
Theme string
|
Theme string
|
||||||
|
|
|
@ -299,7 +299,9 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
|
panic(oops.New(err, "project id was not numeric (bad regex in routing)"))
|
||||||
}
|
}
|
||||||
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{})
|
p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{
|
||||||
|
AlwaysVisibleToOwnerAndStaff: true,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.NotFound) {
|
if errors.Is(err, db.NotFound) {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
|
@ -309,7 +311,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
|
||||||
}
|
}
|
||||||
|
|
||||||
c.CurrentProject = &p.Project
|
c.CurrentProject = &p.Project
|
||||||
c.UrlContext = UrlContextForProject(c.CurrentProject)
|
c.UrlContext = UrlContextForProject(&c.CurrentProject.Project)
|
||||||
|
|
||||||
if !p.Project.Personal {
|
if !p.Project.Personal {
|
||||||
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||||
|
@ -461,7 +463,8 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
||||||
slug := strings.TrimRight(hostPrefix, ".")
|
slug := strings.TrimRight(hostPrefix, ".")
|
||||||
|
|
||||||
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{})
|
if len(slug) > 0 {
|
||||||
|
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
c.CurrentProject = &dbProject.Project
|
c.CurrentProject = &dbProject.Project
|
||||||
} else {
|
} else {
|
||||||
|
@ -471,6 +474,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if c.CurrentProject == nil {
|
if c.CurrentProject == nil {
|
||||||
dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{
|
dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{
|
||||||
|
@ -486,7 +490,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
panic("failed to load project data")
|
panic("failed to load project data")
|
||||||
}
|
}
|
||||||
|
|
||||||
c.UrlContext = UrlContextForProject(c.CurrentProject)
|
c.UrlContext = UrlContextForProject(&c.CurrentProject.Project)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Theme = "light"
|
c.Theme = "light"
|
||||||
|
|
|
@ -99,34 +99,19 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
type projectQuery struct {
|
projectsQuery := ProjectsQuery{
|
||||||
Project models.Project `db:"project"`
|
OwnerIDs: []int{profileUser.ID},
|
||||||
|
Lifecycles: models.VisibleProjectLifecycles,
|
||||||
|
AlwaysVisibleToOwnerAndStaff: true,
|
||||||
|
OrderBy: "all_last_updated DESC",
|
||||||
}
|
}
|
||||||
c.Perf.StartBlock("SQL", "Fetch projects")
|
|
||||||
projectQueryResult, err := db.Query(c.Context(), c.Conn, projectQuery{},
|
projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery)
|
||||||
`
|
templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
|
||||||
SELECT $columns
|
for _, p := range projectsAndStuff {
|
||||||
FROM
|
|
||||||
handmade_project AS project
|
|
||||||
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
|
|
||||||
WHERE
|
|
||||||
uproj.user_id = $1
|
|
||||||
AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3)))
|
|
||||||
`,
|
|
||||||
profileUser.ID,
|
|
||||||
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
|
|
||||||
models.VisibleProjectLifecycles,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for user: %s", username))
|
|
||||||
}
|
|
||||||
projectQuerySlice := projectQueryResult.ToSlice()
|
|
||||||
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
|
|
||||||
for _, projectRow := range projectQuerySlice {
|
|
||||||
projectData := projectRow.(*projectQuery)
|
|
||||||
templateProjects = append(templateProjects, templates.ProjectToTemplate(
|
templateProjects = append(templateProjects, templates.ProjectToTemplate(
|
||||||
&projectData.Project,
|
&p.Project,
|
||||||
UrlContextForProject(&projectData.Project).BuildHomepage(),
|
UrlContextForProject(&p.Project.Project).BuildHomepage(),
|
||||||
c.Theme,
|
c.Theme,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
@ -197,7 +182,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
ProfileUserLinks: profileUserLinks,
|
ProfileUserLinks: profileUserLinks,
|
||||||
ProfileUserProjects: templateProjects,
|
ProfileUserProjects: templateProjects,
|
||||||
TimelineItems: timelineItems,
|
TimelineItems: timelineItems,
|
||||||
OwnProfile: c.CurrentUser.ID == profileUser.ID,
|
OwnProfile: (c.CurrentUser != nil && c.CurrentUser.ID == profileUser.ID),
|
||||||
ShowcaseUrl: hmnurl.BuildShowcase(),
|
ShowcaseUrl: hmnurl.BuildShowcase(),
|
||||||
NewProjectUrl: hmnurl.BuildProjectNew(),
|
NewProjectUrl: hmnurl.BuildProjectNew(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
|
|
Loading…
Reference in New Issue