diff --git a/public/style.css b/public/style.css
index ccc5273d..2ceadad4 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 40071c00..c69c5f6d 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 627dfdf9..1e6a2371 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 a32dec4e..68555ea1 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 9ca4be12..c5ee7264 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 81871c3d..1eca4ea4 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 57f078df..c8e33430 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 3cb644ff..16044dea 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 22a0ecb6..20f21859 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 087ece76..7b154ba8 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 522ec6dc..6c4711da 100644
--- a/src/templates/src/project_edit.html
+++ b/src/templates/src/project_edit.html
@@ -69,6 +69,18 @@
+
{{ if and .Editing .User.IsStaff }}
@@ -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 dc04346c..1d95d7f5 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 d16affc7..9a465279 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 f5bdd08c..ec5bd1ee 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 69dfd443..efc68db1 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 754e1435..3ea279bb 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 8835ee51..3e6be23c 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 c0033c71..1a4e689a 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 db7fc84b..609c77a2 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 b75cfefc..c4f16795 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 00000000..7552ddfe
--- /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 5f6300a7..29c435bf 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()