Add tag management to projects
Also rearrange that ProjectAndLogos stuff because agh it was so weird
This commit is contained in:
parent
f5ed6ec896
commit
73824a027b
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
|
||||||
_, 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)
|
fmt.Printf("Project now has tag: %s\n", tag)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
projectTagCommand.Flags().Int("projectid", 0, "")
|
projectTagCommand.Flags().Int("projectid", 0, "")
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 "><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 //
|
||||||
////////////////////////////
|
////////////////////////////
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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{
|
||||||
|
|
|
@ -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, ""))
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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"
|
||||||
|
@ -23,7 +24,6 @@ type ProjectsQuery struct {
|
||||||
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
||||||
Types ProjectTypeQuery // bitfield
|
Types ProjectTypeQuery // bitfield
|
||||||
IncludeHidden bool
|
IncludeHidden bool
|
||||||
AlwaysVisibleToOwnerAndStaff bool
|
|
||||||
|
|
||||||
// Ignored when using FetchProject
|
// Ignored when using FetchProject
|
||||||
ProjectIDs []int // if empty, all projects
|
ProjectIDs []int // if empty, all projects
|
||||||
|
@ -36,8 +36,23 @@ type ProjectsQuery struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type ProjectAndStuff struct {
|
type ProjectAndStuff struct {
|
||||||
Project models.ProjectWithLogos
|
Project models.Project
|
||||||
|
LogoLightAsset *models.Asset `db:"logolight_asset"`
|
||||||
|
LogoDarkAsset *models.Asset `db:"logodark_asset"`
|
||||||
Owners []*models.User
|
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,
|
||||||
|
LogoLightAsset: row.LogoLightAsset,
|
||||||
|
LogoDarkAsset: row.LogoDarkAsset,
|
||||||
Owners: owners,
|
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
|
||||||
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
|
@ -102,18 +102,15 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
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()
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue