Add tag management to projects

Also rearrange that ProjectAndLogos stuff because agh it was so weird
This commit is contained in:
Ben Visness 2021-12-07 21:37:52 -06:00
parent f5ed6ec896
commit 73824a027b
22 changed files with 363 additions and 188 deletions

View File

@ -8024,6 +8024,10 @@ pre {
.edit-form .edit-form-row .pt-input-ns { .edit-form .edit-form-row .pt-input-ns {
padding-top: 0.3rem; } } 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 { .edit-form.project-edit .project_description {
width: 100%; width: 100%;
min-height: 400px; min-height: 400px;

View File

@ -265,6 +265,7 @@ pre, code, .codeblock {
--form-button-background-active: #303840; --form-button-background-active: #303840;
--form-button-border-color: transparent; --form-button-border-color: transparent;
--form-button-inline-border-color: transparent; --form-button-inline-border-color: transparent;
--form-error-color: #c61d24;
--landing-search-background: #282828; --landing-search-background: #282828;
--landing-search-background-hover: #181818; --landing-search-background-hover: #181818;
--editor-toolbar-background: #282828; --editor-toolbar-background: #282828;

View File

@ -283,6 +283,7 @@ pre, code, .codeblock {
--form-button-background-active: #f2f2f2; --form-button-background-active: #f2f2f2;
--form-button-border-color: #ccc; --form-button-border-color: #ccc;
--form-button-inline-border-color: #999; --form-button-inline-border-color: #999;
--form-error-color: #c61d24;
--landing-search-background: #f8f8f8; --landing-search-background: #f8f8f8;
--landing-search-background-hover: #fefeff; --landing-search-background-hover: #fefeff;
--editor-toolbar-background: #fff; --editor-toolbar-background: #fff;

View File

@ -163,66 +163,16 @@ func addProjectTagCommand(projectCommand *cobra.Command) {
conn := db.NewConnPool(1, 1) conn := db.NewConnPool(1, 1)
defer conn.Close() defer conn.Close()
tx, err := conn.Begin(ctx) resultTag, err := website.SetProjectTag(ctx, conn, projectID, tag)
if err != nil {
panic(err)
}
defer tx.Rollback(ctx)
p, err := website.FetchProject(ctx, tx, nil, projectID, website.ProjectsQuery{
IncludeHidden: true,
Lifecycles: models.AllProjectLifecycles,
})
if err != nil { if err != nil {
panic(err) panic(err)
} }
if p.Project.TagID == nil { if resultTag == nil {
// Create a tag fmt.Printf("Project tag was deleted.\n")
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)
}
} else { } else {
// Update the text of an existing one fmt.Printf("Project now has tag: %s\n", tag)
_, 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)
}
} }
err = tx.Commit(ctx)
if err != nil {
panic(err)
}
fmt.Printf("Project now has tag: %s\n", tag)
}, },
} }
projectTagCommand.Flags().Int("projectid", 0, "") projectTagCommand.Flags().Int("projectid", 0, "")

View File

@ -80,12 +80,6 @@ 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
} }

View File

@ -1,6 +1,25 @@
package models package models
import "regexp"
type Tag struct { type Tag struct {
ID int `db:"id"` ID int `db:"id"`
Text string `db:"text"` 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
}

View File

@ -88,6 +88,10 @@
@extend .mw5-ns; @extend .mw5-ns;
} }
input[type=text]:invalid {
@include usevar(border-color, form-error-color);
}
textarea { textarea {
@extend .w-100; @extend .w-100;
@extend .w6-ns; @extend .w6-ns;

View File

@ -70,6 +70,7 @@ $vars: (
form-button-background-active: #303840, form-button-background-active: #303840,
form-button-border-color: transparent, form-button-border-color: transparent,
form-button-inline-border-color: transparent, form-button-inline-border-color: transparent,
form-error-color: #c61d24,
landing-search-background: #282828, landing-search-background: #282828,
landing-search-background-hover: #181818, landing-search-background-hover: #181818,

View File

@ -70,6 +70,7 @@ $vars: (
form-button-background-active: #f2f2f2, form-button-background-active: #f2f2f2,
form-button-border-color: #ccc, form-button-border-color: #ccc,
form-button-inline-border-color: #999, form-button-inline-border-color: #999,
form-error-color: #c61d24,
landing-search-background: #f8f8f8, landing-search-background: #f8f8f8,
landing-search-background-hover: #fefeff, landing-search-background-hover: #fefeff,

View File

@ -59,23 +59,26 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
models.ProjectLifecycleLTS: "Complete", 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 theme == "dark" {
if p.LogoDarkAsset != nil { if darkAsset != nil {
return hmnurl.BuildS3Asset(p.LogoDarkAsset.S3Key) return hmnurl.BuildS3Asset(darkAsset.S3Key)
} else { } else {
return hmnurl.BuildUserFile(p.Project.LogoDark) return hmnurl.BuildUserFile(p.LogoDark)
} }
} else { } else {
if p.LogoLightAsset != nil { if lightAsset != nil {
return hmnurl.BuildS3Asset(p.LogoLightAsset.S3Key) return hmnurl.BuildS3Asset(lightAsset.S3Key)
} else { } 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{ return Project{
Name: p.Name, Name: p.Name,
Subdomain: p.Subdomain(), Subdomain: p.Subdomain(),
@ -85,8 +88,6 @@ func ProjectToTemplate(p *models.ProjectWithLogos, url string, theme string) Pro
Blurb: p.Blurb, Blurb: p.Blurb,
ParsedDescription: template.HTML(p.ParsedDescription), ParsedDescription: template.HTML(p.ParsedDescription),
Logo: ProjectLogoUrl(p, theme),
LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle], LifecycleBadgeClass: LifecycleBadgeClasses[p.Lifecycle],
LifecycleString: LifecycleBadgeStrings[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{ var ProjectLifecycleValues = map[models.ProjectLifecycle]string{
models.ProjectLifecycleActive: "active", models.ProjectLifecycleActive: "active",
models.ProjectLifecycleHiatus: "hiatus", models.ProjectLifecycleHiatus: "hiatus",
@ -115,7 +120,13 @@ func ProjectLifecycleFromValue(value string) (models.ProjectLifecycle, bool) {
return models.ProjectLifecycleUnapproved, false 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)) ownerUsers := make([]User, 0, len(owners))
for _, owner := range owners { for _, owner := range owners {
ownerUsers = append(ownerUsers, UserToTemplate(owner, currentTheme)) ownerUsers = append(ownerUsers, UserToTemplate(owner, currentTheme))
@ -127,11 +138,12 @@ func ProjectToProjectSettings(p *models.ProjectWithLogos, owners []*models.User,
Featured: p.Featured, Featured: p.Featured,
Personal: p.Personal, Personal: p.Personal,
Lifecycle: ProjectLifecycleValues[p.Lifecycle], Lifecycle: ProjectLifecycleValues[p.Lifecycle],
Tag: tag,
Blurb: p.Blurb, Blurb: p.Blurb,
Description: p.Description, Description: p.Description,
Owners: ownerUsers, Owners: ownerUsers,
LightLogo: ProjectLogoUrl(p, "light"), LightLogo: lightLogoUrl,
DarkLogo: ProjectLogoUrl(p, "dark"), DarkLogo: darkLogoUrl,
} }
} }

View File

@ -69,6 +69,18 @@
</div> </div>
</div> </div>
</div> </div>
<div class="edit-form-row">
<div class="pt-input-ns">Tag:</div>
<div>
<input
id="tag" name="tag" type="text"
pattern="^[a-z0-9]+(-[a-z0-9]+)*$" maxlength="20"
value="{{ .ProjectSettings.Tag }}"
/>
<div class="c--dim f7 mt1">e.g. "imgui" or "text-editor". Tags must be all lowercase, and can use hyphens to separate words.</div>
<div class="c--dim f7" id="tag-discord-info">If you have linked your Discord account, any #project-showcase messages with the tag "&gt;<span id="tag-preview"></span>" will automatically be associated with this project.</div>
</div>
</div>
{{ if and .Editing .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>
@ -91,7 +103,7 @@
<div class="pt-input-ns">Slug:</div> <div class="pt-input-ns">Slug:</div>
<div> <div>
<input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}"> <input type="text" name="slug" maxlength="255" class="textbox" value="{{ .ProjectSettings.Slug }}">
<div class="c--dim f7">Has no effect for personal projects. Personal projects have a slug derived from the title.</div> <div class="c--dim f7 mt1">Has no effect for personal projects. Personal projects have a slug derived from the title.</div>
<div class="c--dim f7">If you change this, make sure to change DNS too!</div> <div class="c--dim f7">If you change this, make sure to change DNS too!</div>
</div> </div>
</div> </div>
@ -185,6 +197,19 @@
switchTab(document.body, tabName); 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 // // Description management //
//////////////////////////// ////////////////////////////

View File

@ -142,6 +142,7 @@ type ProjectSettings struct {
Featured bool Featured bool
Personal bool Personal bool
Lifecycle string Lifecycle string
Tag string
Blurb string Blurb string
Description string Description string

View File

@ -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.ProjectWithLogos var project models.Project
if c.CurrentProject != nil { if c.CurrentProject != nil {
project = *c.CurrentProject project = *c.CurrentProject
} }
@ -51,14 +51,14 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()), LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1), ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1),
Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage(), c.Theme), Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage()),
User: templateUser, User: templateUser,
Session: templateSession, Session: templateSession,
Notices: notices, Notices: notices,
ReportIssueMailto: "team@handmade.network", ReportIssueMailto: "team@handmade.network",
OpenGraphItems: buildDefaultOpenGraphItems(&project.Project, title), OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
IsProjectPage: !project.IsHMN(), IsProjectPage: !project.IsHMN(),
Header: templates.Header{ Header: templates.Header{

View File

@ -166,7 +166,7 @@ func AtomFeed(c *RequestContext) ResponseData {
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"))
} }
for _, p := range projectsAndStuff { 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() templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
for _, owner := range p.Owners { for _, owner := range p.Owners {
templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner, "")) templateProject.Owners = append(templateProject.Owners, templates.UserToTemplate(owner, ""))

View File

@ -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.Project, subforums) sfId, valid := validateSubforums(lineageBuilder, c.CurrentProject, subforums)
if !valid { if !valid {
return commonForumData{}, false return commonForumData{}, false
} }

View File

@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"git.handmade.network/hmn/hmn/src/hmnurl" "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/db"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
@ -20,10 +21,9 @@ const (
type ProjectsQuery struct { type ProjectsQuery struct {
// Available on all project queries // Available on all project queries
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
@ -36,8 +36,23 @@ type ProjectsQuery struct {
} }
type ProjectAndStuff struct { type ProjectAndStuff struct {
Project models.ProjectWithLogos Project models.Project
Owners []*models.User 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( func FetchProjects(
@ -61,6 +76,15 @@ func FetchProjects(
} }
defer tx.Rollback(ctx) 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) // Fetch all valid projects (not yet subject to user permission checks)
var qb db.QueryBuilder var qb db.QueryBuilder
if len(q.OrderBy) > 0 { 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 INNER JOIN handmade_user_projects AS owner_filter ON owner_filter.project_id = project.id
`) `)
} }
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil { if checkOwnerVisibility {
qb.Add(` qb.Add(`
LEFT JOIN handmade_user_projects AS owner_visibility ON owner_visibility.project_id = project.id LEFT JOIN handmade_user_projects AS owner_visibility ON owner_visibility.project_id = project.id
`) `)
@ -87,6 +111,7 @@ func FetchProjects(
WHERE WHERE
TRUE TRUE
`) `)
// Filters // Filters
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)
@ -109,8 +134,8 @@ func FetchProjects(
} }
// Visibility // Visibility
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil { if checkOwnerVisibility {
qb.Add(`AND ($? = TRUE OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID) qb.Add(`AND ($? OR owner_visibility.user_id = $? OR (TRUE`, currentUser.IsStaff, currentUser.ID)
} }
if !q.IncludeHidden { if !q.IncludeHidden {
qb.Add(`AND NOT hidden`) qb.Add(`AND NOT hidden`)
@ -120,7 +145,7 @@ func FetchProjects(
} else { } else {
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles) qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
} }
if q.AlwaysVisibleToOwnerAndStaff && currentUser != nil { if checkOwnerVisibility {
qb.Add(`))`) qb.Add(`))`)
} }
@ -131,16 +156,33 @@ func FetchProjects(
if len(q.OrderBy) > 0 { if len(q.OrderBy) > 0 {
qb.Add(fmt.Sprintf(`) q ORDER BY %s`, q.OrderBy)) 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 { if err != nil {
return nil, oops.New(err, "failed to fetch projects") return nil, oops.New(err, "failed to fetch projects")
} }
iprojects := itProjects.ToSlice() 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 // 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.ProjectWithLogos).ID projectIds[i] = iproject.(*projectRow).Project.ID
} }
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds) projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
if err != nil { if err != nil {
@ -149,7 +191,7 @@ func FetchProjects(
var res []ProjectAndStuff var res []ProjectAndStuff
for i, iproject := range iprojects { for i, iproject := range iprojects {
project := iproject.(*models.ProjectWithLogos) row := iproject.(*projectRow)
owners := projectOwners[i].Owners owners := projectOwners[i].Owners
/* /*
@ -162,7 +204,7 @@ func FetchProjects(
*/ */
var projectVisible bool var projectVisible bool
if project.Personal { if row.Project.Personal {
allOwnersApproved := true allOwnersApproved := true
for _, owner := range owners { for _, owner := range owners {
if owner.Status != models.UserStatusApproved { if owner.Status != models.UserStatusApproved {
@ -180,9 +222,22 @@ func FetchProjects(
} }
if projectVisible { 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{ res = append(res, ProjectAndStuff{
Project: *project, Project: row.Project,
Owners: owners, LogoLightAsset: row.LogoLightAsset,
LogoDarkAsset: row.LogoDarkAsset,
Owners: owners,
Tag: projectTag,
}) })
} }
} }
@ -435,3 +490,78 @@ func UrlContextForProject(p *models.Project) *hmnurl.UrlContext {
ProjectName: p.Name, 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
}

View File

@ -22,6 +22,7 @@ import (
"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" "github.com/google/uuid"
"github.com/jackc/pgx/v4"
) )
type ProjectTemplateData struct { type ProjectTemplateData struct {
@ -71,7 +72,9 @@ 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.Project).BuildHomepage(), c.Theme) templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage())
templateProject.AddLogo(p.LogoURL(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
@ -132,11 +135,9 @@ func ProjectIndex(c *RequestContext) ResponseData {
if i >= maxPersonalProjects { if i >= maxPersonalProjects {
break break
} }
personalProjects = append(personalProjects, templates.ProjectToTemplate( templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage())
&p.Project, templateProject.AddLogo(p.LogoURL(c.Theme))
UrlContextForProject(&p.Project.Project).BuildHomepage(), personalProjects = append(personalProjects, templateProject)
c.Theme,
))
} }
} }
@ -260,7 +261,12 @@ func ProjectHomepage(c *RequestContext) ResponseData {
Value: c.CurrentProject.Blurb, 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 { for _, owner := range owners {
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme)) templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
} }
@ -449,16 +455,28 @@ func ProjectNewSubmit(c *RequestContext) ResponseData {
} }
func ProjectEdit(c *RequestContext) ResponseData { func ProjectEdit(c *RequestContext) ResponseData {
project := c.CurrentProject
if !c.CurrentUserCanEditCurrentProject { if !c.CurrentUserCanEditCurrentProject {
return FourOhFour(c) 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 { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "Failed to fetch project owners")) 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 var res ResponseData
res.MustWriteTemplate("project_edit.html", ProjectEditData{ res.MustWriteTemplate("project_edit.html", ProjectEditData{
@ -520,6 +538,7 @@ type ProjectPayload struct {
OwnerUsernames []string OwnerUsernames []string
LightLogo FormImage LightLogo FormImage
DarkLogo FormImage DarkLogo FormImage
Tag string
Slug string Slug string
Featured bool Featured bool
@ -564,6 +583,12 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
return res 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") hiddenStr := c.Req.Form.Get("hidden")
hidden := len(hiddenStr) > 0 hidden := len(hiddenStr) > 0
@ -601,6 +626,7 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
OwnerUsernames: owners, OwnerUsernames: owners,
LightLogo: lightLogo, LightLogo: lightLogo,
DarkLogo: darkLogo, DarkLogo: darkLogo,
Tag: tag,
Slug: slug, Slug: slug,
Personal: !official, Personal: !official,
Featured: featured, Featured: featured,
@ -609,11 +635,11 @@ func ParseProjectEditForm(c *RequestContext) ProjectEditFormResult {
return res 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 var lightLogoUUID *uuid.UUID
if payload.LightLogo.Exists { if payload.LightLogo.Exists {
lightLogo := &payload.LightLogo lightLogo := &payload.LightLogo
lightLogoAsset, err := assets.Create(ctx, conn, assets.CreateInput{ lightLogoAsset, err := assets.Create(ctx, tx, assets.CreateInput{
Content: lightLogo.Content, Content: lightLogo.Content,
Filename: lightLogo.Filename, Filename: lightLogo.Filename,
ContentType: lightLogo.Mime, ContentType: lightLogo.Mime,
@ -630,7 +656,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay
var darkLogoUUID *uuid.UUID var darkLogoUUID *uuid.UUID
if payload.DarkLogo.Exists { if payload.DarkLogo.Exists {
darkLogo := &payload.DarkLogo darkLogo := &payload.DarkLogo
darkLogoAsset, err := assets.Create(ctx, conn, assets.CreateInput{ darkLogoAsset, err := assets.Create(ctx, tx, assets.CreateInput{
Content: darkLogo.Content, Content: darkLogo.Content,
Filename: darkLogo.Filename, Filename: darkLogo.Filename,
ContentType: darkLogo.Mime, 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) 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 { if err != nil {
return oops.New(err, "Failed to update project") return oops.New(err, "Failed to update project")
} }
SetProjectTag(ctx, tx, payload.ProjectID, payload.Tag)
if user.IsStaff { if user.IsStaff {
_, err = conn.Exec(ctx, _, err = tx.Exec(ctx,
` `
UPDATE handmade_project SET UPDATE handmade_project SET
slug = $2, 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 { if payload.LightLogo.Exists || payload.LightLogo.Remove {
_, err = conn.Exec(ctx, _, err = tx.Exec(ctx,
` `
UPDATE handmade_project UPDATE handmade_project
SET SET
@ -721,7 +749,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay
} }
if payload.DarkLogo.Exists || payload.DarkLogo.Remove { if payload.DarkLogo.Exists || payload.DarkLogo.Remove {
_, err = conn.Exec(ctx, _, err = tx.Exec(ctx,
` `
UPDATE handmade_project UPDATE handmade_project
SET 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 SELECT $columns
FROM auth_user FROM auth_user
@ -750,7 +778,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay
} }
ownerRows := ownerResult.ToSlice() ownerRows := ownerResult.ToSlice()
_, err = conn.Exec(ctx, _, err = tx.Exec(ctx,
` `
DELETE FROM handmade_user_projects DELETE FROM handmade_user_projects
WHERE project_id = $1 WHERE project_id = $1
@ -762,7 +790,7 @@ func updateProject(ctx context.Context, conn db.ConnOrTx, user *models.User, pay
} }
for _, ownerRow := range ownerRows { for _, ownerRow := range ownerRows {
_, err = conn.Exec(ctx, _, err = tx.Exec(ctx,
` `
INSERT INTO handmade_user_projects INSERT INTO handmade_user_projects
(user_id, project_id) (user_id, project_id)

View File

@ -160,7 +160,7 @@ type RequestContext struct {
Res http.ResponseWriter Res http.ResponseWriter
Conn *pgxpool.Pool Conn *pgxpool.Pool
CurrentProject *models.ProjectWithLogos CurrentProject *models.Project
CurrentUser *models.User CurrentUser *models.User
CurrentSession *models.Session CurrentSession *models.Session
Theme string Theme string

View File

@ -299,9 +299,7 @@ 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)
@ -311,7 +309,7 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool) ht
} }
c.CurrentProject = &p.Project c.CurrentProject = &p.Project
c.UrlContext = UrlContextForProject(&c.CurrentProject.Project) c.UrlContext = UrlContextForProject(c.CurrentProject)
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)
@ -465,7 +463,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
var owners []*models.User var owners []*models.User
if len(slug) > 0 { 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 { if err == nil {
c.CurrentProject = &dbProject.Project c.CurrentProject = &dbProject.Project
owners = dbProject.Owners owners = dbProject.Owners
@ -507,7 +505,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
} }
c.CurrentUserCanEditCurrentProject = canEditProject c.CurrentUserCanEditCurrentProject = canEditProject
c.UrlContext = UrlContextForProject(&c.CurrentProject.Project) c.UrlContext = UrlContextForProject(c.CurrentProject)
} }
c.Theme = "light" c.Theme = "light"

View File

@ -152,60 +152,3 @@ func FetchSnippet(
return res[0], nil 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
}

66
src/website/tag_helper.go Normal file
View File

@ -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
}

View File

@ -100,20 +100,17 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.EndBlock() c.Perf.EndBlock()
projectsQuery := ProjectsQuery{ projectsQuery := ProjectsQuery{
OwnerIDs: []int{profileUser.ID}, OwnerIDs: []int{profileUser.ID},
Lifecycles: models.VisibleProjectLifecycles, Lifecycles: models.VisibleProjectLifecycles,
AlwaysVisibleToOwnerAndStaff: true, OrderBy: "all_last_updated DESC",
OrderBy: "all_last_updated DESC",
} }
projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery) projectsAndStuff, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, projectsQuery)
templateProjects := make([]templates.Project, 0, len(projectsAndStuff)) templateProjects := make([]templates.Project, 0, len(projectsAndStuff))
for _, p := range projectsAndStuff { for _, p := range projectsAndStuff {
templateProjects = append(templateProjects, templates.ProjectToTemplate( templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage())
&p.Project, templateProject.AddLogo(p.LogoURL(c.Theme))
UrlContextForProject(&p.Project.Project).BuildHomepage(), templateProjects = append(templateProjects, templateProject)
c.Theme,
))
} }
c.Perf.EndBlock() c.Perf.EndBlock()