diff --git a/public/style.css b/public/style.css index ccc5273..2ceadad 100644 --- a/public/style.css +++ b/public/style.css @@ -8024,6 +8024,10 @@ pre { .edit-form .edit-form-row .pt-input-ns { padding-top: 0.3rem; } } +.edit-form input[type=text]:invalid { + border-color: #c61d24; + border-color: var(--form-error-color); } + .edit-form.project-edit .project_description { width: 100%; min-height: 400px; diff --git a/public/themes/dark/theme.css b/public/themes/dark/theme.css index 40071c0..c69c5f6 100644 --- a/public/themes/dark/theme.css +++ b/public/themes/dark/theme.css @@ -265,6 +265,7 @@ pre, code, .codeblock { --form-button-background-active: #303840; --form-button-border-color: transparent; --form-button-inline-border-color: transparent; + --form-error-color: #c61d24; --landing-search-background: #282828; --landing-search-background-hover: #181818; --editor-toolbar-background: #282828; diff --git a/public/themes/light/theme.css b/public/themes/light/theme.css index 627dfdf..1e6a237 100644 --- a/public/themes/light/theme.css +++ b/public/themes/light/theme.css @@ -283,6 +283,7 @@ pre, code, .codeblock { --form-button-background-active: #f2f2f2; --form-button-border-color: #ccc; --form-button-inline-border-color: #999; + --form-error-color: #c61d24; --landing-search-background: #f8f8f8; --landing-search-background-hover: #fefeff; --editor-toolbar-background: #fff; diff --git a/src/admintools/adminproject.go b/src/admintools/adminproject.go index a32dec4..68555ea 100644 --- a/src/admintools/adminproject.go +++ b/src/admintools/adminproject.go @@ -163,66 +163,16 @@ func addProjectTagCommand(projectCommand *cobra.Command) { conn := db.NewConnPool(1, 1) defer conn.Close() - tx, err := conn.Begin(ctx) - if err != nil { - panic(err) - } - defer tx.Rollback(ctx) - - p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{ - IncludeHidden: true, - Lifecycles: models.AllProjectLifecycles, - }) + resultTag, err := website.SetProjectTag(ctx, conn, projectID, tag) if err != nil { panic(err) } - if p.Project.TagID == nil { - // Create a tag - tagID, err := db.QueryInt(ctx, tx, - ` - INSERT INTO tags (text) VALUES ($1) - RETURNING id - `, - tag, - ) - if err != nil { - panic(err) - } - - // Attach it to the project - _, err = tx.Exec(ctx, - ` - UPDATE handmade_project - SET tag = $1 - WHERE id = $2 - `, - tagID, projectID, - ) - if err != nil { - panic(err) - } + if resultTag == nil { + fmt.Printf("Project tag was deleted.\n") } else { - // Update the text of an existing one - _, err := tx.Exec(ctx, - ` - UPDATE tags - SET text = $1 - WHERE id = (SELECT tag FROM handmade_project WHERE id = $2) - `, - tag, projectID, - ) - if err != nil { - panic(err) - } + fmt.Printf("Project now has tag: %s\n", tag) } - - err = tx.Commit(ctx) - if err != nil { - panic(err) - } - - fmt.Printf("Project now has tag: %s\n", tag) }, } projectTagCommand.Flags().Int("projectid", 0, "") diff --git a/src/models/project.go b/src/models/project.go index 9ca4be1..c5ee726 100644 --- a/src/models/project.go +++ b/src/models/project.go @@ -80,12 +80,6 @@ type Project struct { 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 { return p.ID == HMNProjectID } diff --git a/src/models/tag.go b/src/models/tag.go index 81871c3..1eca4ea 100644 --- a/src/models/tag.go +++ b/src/models/tag.go @@ -1,6 +1,25 @@ package models +import "regexp" + type Tag struct { ID int `db:"id"` Text string `db:"text"` } + +var REValidTag = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) + +func ValidateTagText(text string) bool { + if text == "" { + return true + } + + if len(text) > 20 { + return false + } + if !REValidTag.MatchString(text) { + return false + } + + return true +} diff --git a/src/rawdata/scss/_editor.scss b/src/rawdata/scss/_editor.scss index 57f078d..c8e3343 100644 --- a/src/rawdata/scss/_editor.scss +++ b/src/rawdata/scss/_editor.scss @@ -88,6 +88,10 @@ @extend .mw5-ns; } + input[type=text]:invalid { + @include usevar(border-color, form-error-color); + } + textarea { @extend .w-100; @extend .w6-ns; diff --git a/src/rawdata/scss/themes/dark/_variables.scss b/src/rawdata/scss/themes/dark/_variables.scss index 3cb644f..16044de 100644 --- a/src/rawdata/scss/themes/dark/_variables.scss +++ b/src/rawdata/scss/themes/dark/_variables.scss @@ -70,6 +70,7 @@ $vars: ( form-button-background-active: #303840, form-button-border-color: transparent, form-button-inline-border-color: transparent, + form-error-color: #c61d24, landing-search-background: #282828, landing-search-background-hover: #181818, diff --git a/src/rawdata/scss/themes/light/_variables.scss b/src/rawdata/scss/themes/light/_variables.scss index 22a0ecb..20f2185 100644 --- a/src/rawdata/scss/themes/light/_variables.scss +++ b/src/rawdata/scss/themes/light/_variables.scss @@ -70,6 +70,7 @@ $vars: ( form-button-background-active: #f2f2f2, form-button-border-color: #ccc, form-button-inline-border-color: #999, + form-error-color: #c61d24, landing-search-background: #f8f8f8, landing-search-background-hover: #fefeff, diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 087ece7..7b154ba 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -59,23 +59,26 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ models.ProjectLifecycleLTS: "Complete", } -func ProjectLogoUrl(p *models.ProjectWithLogos, theme string) string { +func ProjectLogoUrl(p *models.Project, lightAsset *models.Asset, darkAsset *models.Asset, theme string) string { if theme == "dark" { - if p.LogoDarkAsset != nil { - return hmnurl.BuildS3Asset(p.LogoDarkAsset.S3Key) + if darkAsset != nil { + return hmnurl.BuildS3Asset(darkAsset.S3Key) } else { - return hmnurl.BuildUserFile(p.Project.LogoDark) + return hmnurl.BuildUserFile(p.LogoDark) } } else { - if p.LogoLightAsset != nil { - return hmnurl.BuildS3Asset(p.LogoLightAsset.S3Key) + if lightAsset != nil { + return hmnurl.BuildS3Asset(lightAsset.S3Key) } else { - return hmnurl.BuildUserFile(p.Project.LogoLight) + return hmnurl.BuildUserFile(p.LogoLight) } } } -func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Project { +func ProjectToTemplate( + p *models.Project, + url string, +) Project { return Project{ Name: p.Name, Subdomain: p.Subdomain(), @@ -85,8 +88,6 @@ func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Pro Blurb: p.Blurb, ParsedDescription: template.HTML(p.ParsedDescription), - Logo: ProjectLogoUrl(p, theme), - LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle], LifecycleString: LifecycleBadgeStrings[p.Lifecycle], @@ -99,6 +100,10 @@ func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Pro } } +func (p *Project) AddLogo(logoUrl string) { + p.Logo = logoUrl +} + var ProjectLifecycleValues = map[models.ProjectLifecycle]string{ models.ProjectLifecycleActive: "active", models.ProjectLifecycleHiatus: "hiatus", @@ -115,7 +120,13 @@ func ProjectLifecycleFromValue(value string) (models.ProjectLifecycle, bool) { return models.ProjectLifecycleUnapproved, false } -func ProjectToProjectSettings(p *models.ProjectWithLogos, owners []*models.User, currentTheme string) ProjectSettings { +func ProjectToProjectSettings( + p *models.Project, + owners []*models.User, + tag string, + lightLogoUrl, darkLogoUrl string, + currentTheme string, +) ProjectSettings { ownerUsers := make([]User, 0, len(owners)) for _, owner := range owners { ownerUsers = append(ownerUsers, UserToTemplate(owner, currentTheme)) @@ -127,11 +138,12 @@ func ProjectToProjectSettings(p *models.ProjectWithLogos, owners []*models.User, Featured: p.Featured, Personal: p.Personal, Lifecycle: ProjectLifecycleValues[p.Lifecycle], + Tag: tag, Blurb: p.Blurb, Description: p.Description, Owners: ownerUsers, - LightLogo: ProjectLogoUrl(p, "light"), - DarkLogo: ProjectLogoUrl(p, "dark"), + LightLogo: lightLogoUrl, + DarkLogo: darkLogoUrl, } } diff --git a/src/templates/src/project_edit.html b/src/templates/src/project_edit.html index 522ec6d..6c4711d 100644 --- a/src/templates/src/project_edit.html +++ b/src/templates/src/project_edit.html @@ -69,6 +69,18 @@ +
+
Tag:
+
+ +
e.g. "imgui" or "text-editor". Tags must be all lowercase, and can use hyphens to separate words.
+
If you have linked your Discord account, any #project-showcase messages with the tag ">" will automatically be associated with this project.
+
+
{{ if and .Editing .User.IsStaff }}
Admin settings
@@ -91,7 +103,7 @@
Slug:
-
Has no effect for personal projects. Personal projects have a slug derived from the title.
+
Has no effect for personal projects. Personal projects have a slug derived from the title.
If you change this, make sure to change DNS too!
@@ -185,6 +197,19 @@ switchTab(document.body, tabName); } + ////////// + // Tags // + ////////// + + const tag = document.querySelector('#tag'); + const tagPreview = document.querySelector('#tag-preview'); + function updateTagPreview() { + tagPreview.innerText = tag.value; + document.querySelector('#tag-discord-info').classList.toggle('dn', tag.value.length === 0); + } + updateTagPreview(); + tag.addEventListener('input', () => updateTagPreview()); + //////////////////////////// // Description management // //////////////////////////// diff --git a/src/templates/types.go b/src/templates/types.go index dc04346..1d95d7f 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -142,6 +142,7 @@ type ProjectSettings struct { Featured bool Personal bool Lifecycle string + Tag string Blurb string Description string diff --git a/src/website/base_data.go b/src/website/base_data.go index d16affc..9a46527 100644 --- a/src/website/base_data.go +++ b/src/website/base_data.go @@ -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. // If you pass nil, no breadcrumbs will be created. func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData { - var project models.ProjectWithLogos + var project models.Project if c.CurrentProject != nil { project = *c.CurrentProject } @@ -51,14 +51,14 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1), - Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage(), c.Theme), + Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage()), User: templateUser, Session: templateSession, Notices: notices, ReportIssueMailto: "team@handmade.network", - OpenGraphItems: buildDefaultOpenGraphItems(&project.Project, title), + OpenGraphItems: buildDefaultOpenGraphItems(&project, title), IsProjectPage: !project.IsHMN(), Header: templates.Header{ diff --git a/src/website/feed.go b/src/website/feed.go index f5bdd08..ec5bd1e 100644 --- a/src/website/feed.go +++ b/src/website/feed.go @@ -166,7 +166,7 @@ func AtomFeed(c *RequestContext) ResponseData { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed projects")) } for _, p := range projectsAndStuff { - templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme) + templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage()) templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN() for _, owner := range p.Owners { templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner, "")) diff --git a/src/website/forums.go b/src/website/forums.go index 69dfd44..efc68db 100644 --- a/src/website/forums.go +++ b/src/website/forums.go @@ -865,7 +865,7 @@ func getCommonForumData(c *RequestContext) (commonForumData, bool) { } if subforums, hasSubforums := c.PathParams["subforums"]; hasSubforums { - sfId, valid := validateSubforums(lineageBuilder, &c.CurrentProject.Project, subforums) + sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums) if !valid { return commonForumData{}, false } diff --git a/src/website/project_helper.go b/src/website/project_helper.go index 754e143..3ea279b 100644 --- a/src/website/project_helper.go +++ b/src/website/project_helper.go @@ -5,6 +5,7 @@ import ( "fmt" "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/models" @@ -20,10 +21,9 @@ const ( type ProjectsQuery struct { // Available on all project queries - Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles - Types ProjectTypeQuery // bitfield - IncludeHidden bool - AlwaysVisibleToOwnerAndStaff bool + Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles + Types ProjectTypeQuery // bitfield + IncludeHidden bool // Ignored when using FetchProject ProjectIDs []int // if empty, all projects @@ -36,8 +36,23 @@ type ProjectsQuery struct { } type ProjectAndStuff struct { - Project models.ProjectWithLogos - Owners []*models.User + Project models.Project + LogoLightAsset *models.Asset `db:"logolight_asset"` + LogoDarkAsset *models.Asset `db:"logodark_asset"` + Owners []*models.User + Tag *models.Tag +} + +func (p *ProjectAndStuff) TagText() string { + if p.Tag == nil { + return "" + } else { + return p.Tag.Text + } +} + +func (p *ProjectAndStuff) LogoURL(theme string) string { + return templates.ProjectLogoUrl(&p.Project, p.LogoLightAsset, p.LogoDarkAsset, theme) } func FetchProjects( @@ -61,6 +76,15 @@ func FetchProjects( } defer tx.Rollback(ctx) + type projectRow struct { + Project models.Project `db:"project"` + LogoLightAsset *models.Asset `db:"logolight_asset"` + LogoDarkAsset *models.Asset `db:"logodark_asset"` + } + + // If true, join against the project owners table and check visibility permissions + checkOwnerVisibility := q.IncludeHidden && currentUser != nil + // Fetch all valid projects (not yet subject to user permission checks) var qb db.QueryBuilder if len(q.OrderBy) > 0 { @@ -78,7 +102,7 @@ func FetchProjects( INNER JOIN handmade_user_projects AS owner_filter ON owner_filter.project_id = project.id `) } - if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil { + if checkOwnerVisibility { qb.Add(` LEFT JOIN handmade_user_projects AS owner_visibility ON owner_visibility.project_id = project.id `) @@ -87,6 +111,7 @@ func FetchProjects( WHERE TRUE `) + // Filters if len(q.ProjectIDs) > 0 { qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs) @@ -109,8 +134,8 @@ func FetchProjects( } // Visibility - if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil { - qb.Add(`AND ($? = TRUE OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID) + if checkOwnerVisibility { + qb.Add(`AND ($? OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID) } if !q.IncludeHidden { qb.Add(`AND NOT hidden`) @@ -120,7 +145,7 @@ func FetchProjects( } else { qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles) } - if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil { + if checkOwnerVisibility { qb.Add(`))`) } @@ -131,16 +156,33 @@ func FetchProjects( 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()...) + + // Do the query + itProjects, err := db.Query(ctx, dbConn, projectRow{}, qb.String(), qb.Args()...) if err != nil { return nil, oops.New(err, "failed to fetch projects") } iprojects := itProjects.ToSlice() + // Fetch project tags + var tagIDs []int + for _, iproject := range iprojects { + tagID := iproject.(*projectRow).Project.TagID + if tagID != nil { + tagIDs = append(tagIDs, *tagID) + } + } + tags, err := FetchTags(ctx, tx, TagQuery{ + IDs: tagIDs, + }) + if err != nil { + return nil, err + } + // Fetch project owners to do permission checks projectIds := make([]int, len(iprojects)) for i, iproject := range iprojects { - projectIds[i] = iproject.(*models.ProjectWithLogos).ID + projectIds[i] = iproject.(*projectRow).Project.ID } projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) if err != nil { @@ -149,7 +191,7 @@ func FetchProjects( var res []ProjectAndStuff for i, iproject := range iprojects { - project := iproject.(*models.ProjectWithLogos) + row := iproject.(*projectRow) owners := projectOwners[i].Owners /* @@ -162,7 +204,7 @@ func FetchProjects( */ var projectVisible bool - if project.Personal { + if row.Project.Personal { allOwnersApproved := true for _, owner := range owners { if owner.Status != models.UserStatusApproved { @@ -180,9 +222,22 @@ func FetchProjects( } if projectVisible { + var projectTag *models.Tag + if row.Project.TagID != nil { + for _, tag := range tags { + if tag.ID == *row.Project.TagID { + projectTag = tag + break + } + } + } + res = append(res, ProjectAndStuff{ - Project: *project, - Owners: owners, + Project: row.Project, + LogoLightAsset: row.LogoLightAsset, + LogoDarkAsset: row.LogoDarkAsset, + Owners: owners, + Tag: projectTag, }) } } @@ -435,3 +490,78 @@ func UrlContextForProject(p *models.Project) *hmnurl.UrlContext { ProjectName: p.Name, } } + +func SetProjectTag( + ctx context.Context, + dbConn db.ConnOrTx, + projectID int, + tagText string, +) (*models.Tag, error) { + tx, err := dbConn.Begin(ctx) + if err != nil { + return nil, oops.New(err, "failed to start transaction") + } + defer tx.Rollback(ctx) + + p, err := FetchProject(ctx, tx, nil, projectID, ProjectsQuery{ + IncludeHidden: true, + Lifecycles: models.AllProjectLifecycles, + }) + if err != nil { + return nil, err + } + + var resultTag *models.Tag + if tagText == "" { + // Once a project's tag is set, it cannot be unset. Return the existing tag. + resultTag = p.Tag + } else if p.Project.TagID == nil { + // Create a tag + itag, err := db.QueryOne(ctx, tx, models.Tag{}, + ` + INSERT INTO tags (text) VALUES ($1) + RETURNING $columns + `, + tagText, + ) + if err != nil { + return nil, oops.New(err, "failed to create new tag for project") + } + resultTag = itag.(*models.Tag) + + // Attach it to the project + _, err = tx.Exec(ctx, + ` + UPDATE handmade_project + SET tag = $1 + WHERE id = $2 + `, + resultTag.ID, projectID, + ) + if err != nil { + return nil, oops.New(err, "failed to attach new tag to project") + } + } else { + // Update the text of an existing one + itag, err := db.QueryOne(ctx, tx, models.Tag{}, + ` + UPDATE tags + SET text = $1 + WHERE id = (SELECT tag FROM handmade_project WHERE id = $2) + RETURNING $columns + `, + tagText, projectID, + ) + if err != nil { + return nil, oops.New(err, "failed to update existing tag") + } + resultTag = itag.(*models.Tag) + } + + err = tx.Commit(ctx) + if err != nil { + return nil, oops.New(err, "failed to commit transaction") + } + + return resultTag, nil +} diff --git a/src/website/projects.go b/src/website/projects.go index 8835ee5..3e6be23 100644 --- a/src/website/projects.go +++ b/src/website/projects.go @@ -22,6 +22,7 @@ import ( "git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/utils" "github.com/google/uuid" + "github.com/jackc/pgx/v4" ) type ProjectTemplateData struct { @@ -71,7 +72,9 @@ func ProjectIndex(c *RequestContext) ResponseData { var restProjects []templates.Project now := time.Now() for _, p := range officialProjects { - templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project.Project).BuildHomepage(), c.Theme) + templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage()) + templateProject.AddLogo(p.LogoURL(c.Theme)) + if p.Project.Slug == "hero" { // NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list. handmadeHero = &templateProject @@ -132,11 +135,9 @@ func ProjectIndex(c *RequestContext) ResponseData { if i >= maxPersonalProjects { break } - personalProjects = append(personalProjects, templates.ProjectToTemplate( - &p.Project, - UrlContextForProject(&p.Project.Project).BuildHomepage(), - c.Theme, - )) + templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage()) + templateProject.AddLogo(p.LogoURL(c.Theme)) + personalProjects = append(personalProjects, templateProject) } } @@ -260,7 +261,12 @@ func ProjectHomepage(c *RequestContext) ResponseData { Value: c.CurrentProject.Blurb, }) - templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme) + p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, c.CurrentProject.ID, ProjectsQuery{}) + if err != nil { + return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project details")) + } + templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage()) + templateData.Project.AddLogo(p.LogoURL(c.Theme)) for _, owner := range owners { templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme)) } @@ -449,16 +455,28 @@ func ProjectNewSubmit(c *RequestContext) ResponseData { } func ProjectEdit(c *RequestContext) ResponseData { - project := c.CurrentProject if !c.CurrentUserCanEditCurrentProject { return FourOhFour(c) } - owners, err := FetchProjectOwners(c.Context(), c.Conn, project.ID) + p, err := FetchProject( + c.Context(), c.Conn, + c.CurrentUser, c.CurrentProject.ID, + ProjectsQuery{ + IncludeHidden: true, + }, + ) + owners, err := FetchProjectOwners(c.Context(), c.Conn, p.Project.ID) if err != nil { return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch project owners")) } - projectSettings := templates.ProjectToProjectSettings(project, owners, c.Theme) + projectSettings := templates.ProjectToProjectSettings( + &p.Project, + owners, + p.TagText(), + p.LogoURL("light"), p.LogoURL("dark"), + c.Theme, + ) var res ResponseData res.MustWriteTemplate("project_edit.html", ProjectEditData{ @@ -520,6 +538,7 @@ type ProjectPayload struct { OwnerUsernames []string LightLogo FormImage DarkLogo FormImage + Tag string Slug string Featured bool @@ -564,6 +583,12 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult { return res } + tag := c.Req.Form.Get("tag") + if !models.ValidateTagText(tag) { + res.RejectionReason = "Project tag is invalid" + return res + } + hiddenStr := c.Req.Form.Get("hidden") hidden := len(hiddenStr) > 0 @@ -601,6 +626,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult { OwnerUsernames: owners, LightLogo: lightLogo, DarkLogo: darkLogo, + Tag: tag, Slug: slug, Personal: !official, Featured: featured, @@ -609,11 +635,11 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult { return res } -func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, payload *ProjectPayload) error { +func updateProject(ctx context.Context, tx pgx.Tx, user *models.User, payload *ProjectPayload) error { var lightLogoUUID *uuid.UUID if payload.LightLogo.Exists { lightLogo := &payload.LightLogo - lightLogoAsset, err := assets.Create(ctx, conn, assets.CreateInput{ + lightLogoAsset, err := assets.Create(ctx, tx, assets.CreateInput{ Content: lightLogo.Content, Filename: lightLogo.Filename, ContentType: lightLogo.Mime, @@ -630,7 +656,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay var darkLogoUUID *uuid.UUID if payload.DarkLogo.Exists { darkLogo := &payload.DarkLogo - darkLogoAsset, err := assets.Create(ctx, conn, assets.CreateInput{ + darkLogoAsset, err := assets.Create(ctx, tx, assets.CreateInput{ Content: darkLogo.Content, Filename: darkLogo.Filename, ContentType: darkLogo.Mime, @@ -678,13 +704,15 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay } qb.Add(`WHERE id = $?`, payload.ProjectID) - _, err := conn.Exec(ctx, qb.String(), qb.Args()...) + _, err := tx.Exec(ctx, qb.String(), qb.Args()...) if err != nil { return oops.New(err, "Failed to update project") } + SetProjectTag(ctx, tx, payload.ProjectID, payload.Tag) + if user.IsStaff { - _, err = conn.Exec(ctx, + _, err = tx.Exec(ctx, ` UPDATE handmade_project SET slug = $2, @@ -704,7 +732,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay } if payload.LightLogo.Exists || payload.LightLogo.Remove { - _, err = conn.Exec(ctx, + _, err = tx.Exec(ctx, ` UPDATE handmade_project SET @@ -721,7 +749,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay } if payload.DarkLogo.Exists || payload.DarkLogo.Remove { - _, err = conn.Exec(ctx, + _, err = tx.Exec(ctx, ` UPDATE handmade_project SET @@ -737,7 +765,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay } } - ownerResult, err := db.Query(ctx, conn, models.User{}, + ownerResult, err := db.Query(ctx, tx, models.User{}, ` SELECT $columns FROM auth_user @@ -750,7 +778,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay } ownerRows := ownerResult.ToSlice() - _, err = conn.Exec(ctx, + _, err = tx.Exec(ctx, ` DELETE FROM handmade_user_projects WHERE project_id = $1 @@ -762,7 +790,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay } for _, ownerRow := range ownerRows { - _, err = conn.Exec(ctx, + _, err = tx.Exec(ctx, ` INSERT INTO handmade_user_projects (user_id, project_id) diff --git a/src/website/requesthandling.go b/src/website/requesthandling.go index c0033c7..1a4e689 100644 --- a/src/website/requesthandling.go +++ b/src/website/requesthandling.go @@ -160,7 +160,7 @@ type RequestContext struct { Res http.ResponseWriter Conn *pgxpool.Pool - CurrentProject *models.ProjectWithLogos + CurrentProject *models.Project CurrentUser *models.User CurrentSession *models.Session Theme string diff --git a/src/website/routes.go b/src/website/routes.go index db7fc84..609c77a 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -299,9 +299,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht if err != nil { panic(oops.New(err, "project id was not numeric (bad regex in routing)")) } - p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{ - AlwaysVisibleToOwnerAndStaff: true, - }) + p, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, id, ProjectsQuery{}) if err != nil { if errors.Is(err, db.NotFound) { return FourOhFour(c) @@ -311,7 +309,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht } c.CurrentProject = &p.Project - c.UrlContext = UrlContextForProject(&c.CurrentProject.Project) + c.UrlContext = UrlContextForProject(c.CurrentProject) if !p.Project.Personal { return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther) @@ -465,7 +463,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { var owners []*models.User if len(slug) > 0 { - dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{AlwaysVisibleToOwnerAndStaff: true}) + dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{}) if err == nil { c.CurrentProject = &dbProject.Project owners = dbProject.Owners @@ -507,7 +505,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { } c.CurrentUserCanEditCurrentProject = canEditProject - c.UrlContext = UrlContextForProject(&c.CurrentProject.Project) + c.UrlContext = UrlContextForProject(c.CurrentProject) } c.Theme = "light" diff --git a/src/website/snippet_helper.go b/src/website/snippet_helper.go index b75cfef..c4f1679 100644 --- a/src/website/snippet_helper.go +++ b/src/website/snippet_helper.go @@ -152,60 +152,3 @@ func FetchSnippet( return res[0], nil } - -type TagQuery struct { - IDs []int - Text []string - - Limit, Offset int -} - -func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.Tag, error) { - perf := ExtractPerf(ctx) - perf.StartBlock("SQL", "Fetch snippets") - defer perf.EndBlock() - - var qb db.QueryBuilder - qb.Add( - ` - SELECT $columns - FROM tags - WHERE - TRUE - `, - ) - if len(q.IDs) > 0 { - qb.Add(`AND id = ANY ($?)`, q.IDs) - } - if len(q.Text) > 0 { - qb.Add(`AND text = ANY ($?)`, q.Text) - } - if q.Limit > 0 { - qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) - } - - it, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...) - if err != nil { - return nil, oops.New(err, "failed to fetch tags") - } - itags := it.ToSlice() - - res := make([]*models.Tag, len(itags)) - for i, itag := range itags { - tag := itag.(*models.Tag) - res[i] = tag - } - - return res, nil -} - -func FetchTag(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) (*models.Tag, error) { - tags, err := FetchTags(ctx, dbConn, q) - if err != nil { - return nil, err - } - if len(tags) == 0 { - return nil, db.NotFound - } - return tags[0], nil -} diff --git a/src/website/tag_helper.go b/src/website/tag_helper.go new file mode 100644 index 0000000..7552ddf --- /dev/null +++ b/src/website/tag_helper.go @@ -0,0 +1,66 @@ +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 TagQuery struct { + IDs []int + Text []string + + Limit, Offset int +} + +func FetchTags(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) ([]*models.Tag, error) { + perf := ExtractPerf(ctx) + perf.StartBlock("SQL", "Fetch snippets") + defer perf.EndBlock() + + var qb db.QueryBuilder + qb.Add( + ` + SELECT $columns + FROM tags + WHERE + TRUE + `, + ) + if len(q.IDs) > 0 { + qb.Add(`AND id = ANY ($?)`, q.IDs) + } + if len(q.Text) > 0 { + qb.Add(`AND text = ANY ($?)`, q.Text) + } + if q.Limit > 0 { + qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset) + } + + it, err := db.Query(ctx, dbConn, models.Tag{}, qb.String(), qb.Args()...) + if err != nil { + return nil, oops.New(err, "failed to fetch tags") + } + itags := it.ToSlice() + + res := make([]*models.Tag, len(itags)) + for i, itag := range itags { + tag := itag.(*models.Tag) + res[i] = tag + } + + return res, nil +} + +func FetchTag(ctx context.Context, dbConn db.ConnOrTx, q TagQuery) (*models.Tag, error) { + tags, err := FetchTags(ctx, dbConn, q) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, db.NotFound + } + return tags[0], nil +} diff --git a/src/website/user.go b/src/website/user.go index 5f6300a..29c435b 100644 --- a/src/website/user.go +++ b/src/website/user.go @@ -100,20 +100,17 @@ func UserProfile(c *RequestContext) ResponseData { c.Perf.EndBlock() projectsQuery := ProjectsQuery{ - OwnerIDs: []int{profileUser.ID}, - Lifecycles: models.VisibleProjectLifecycles, - AlwaysVisibleToOwnerAndStaff: true, - OrderBy: "all_last_updated DESC", + OwnerIDs: []int{profileUser.ID}, + Lifecycles: models.VisibleProjectLifecycles, + OrderBy: "all_last_updated DESC", } projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery) templateProjects := make([]templates.Project, 0, len(projectsAndStuff)) for _, p := range projectsAndStuff { - templateProjects = append(templateProjects, templates.ProjectToTemplate( - &p.Project, - UrlContextForProject(&p.Project.Project).BuildHomepage(), - c.Theme, - )) + templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage()) + templateProject.AddLogo(p.LogoURL(c.Theme)) + templateProjects = append(templateProjects, templateProject) } c.Perf.EndBlock()