Merge branch 'personal-projects'
This commit is contained in:
commit
908fa4368f
|
@ -10,7 +10,7 @@ set -euxo pipefail
|
||||||
|
|
||||||
THIS_PATH=$(pwd)
|
THIS_PATH=$(pwd)
|
||||||
#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
|
#BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
|
||||||
BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
|
BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
|
||||||
|
|
||||||
pushd $BETA_PATH
|
pushd $BETA_PATH
|
||||||
docker-compose down -v
|
docker-compose down -v
|
||||||
|
@ -19,4 +19,5 @@ pushd $BETA_PATH
|
||||||
|
|
||||||
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
|
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
|
||||||
popd
|
popd
|
||||||
|
#go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06
|
||||||
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23
|
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23
|
||||||
|
|
|
@ -7,6 +7,7 @@ const TimelineMediaTypes = {
|
||||||
|
|
||||||
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
|
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
|
||||||
const modalTemplate = makeTemplateCloner("timeline_modal");
|
const modalTemplate = makeTemplateCloner("timeline_modal");
|
||||||
|
const tagTemplate = makeTemplateCloner("timeline_item_tag");
|
||||||
|
|
||||||
function showcaseTimestamp(rawDate) {
|
function showcaseTimestamp(rawDate) {
|
||||||
const date = new Date(rawDate*1000);
|
const date = new Date(rawDate*1000);
|
||||||
|
@ -95,6 +96,17 @@ function makeShowcaseItem(timelineItem) {
|
||||||
modalEl.date.textContent = timestamp;
|
modalEl.date.textContent = timestamp;
|
||||||
modalEl.date.setAttribute("href", timelineItem.snippet_url);
|
modalEl.date.setAttribute("href", timelineItem.snippet_url);
|
||||||
|
|
||||||
|
if (timelineItem.tags.length === 0) {
|
||||||
|
modalEl.tags.remove();
|
||||||
|
} else {
|
||||||
|
for (const tag of timelineItem.tags) {
|
||||||
|
const tagItem = tagTemplate();
|
||||||
|
tagItem.tag.innerText = tag.text;
|
||||||
|
|
||||||
|
modalEl.tags.appendChild(tagItem.root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
modalEl.discord_link.href = timelineItem.discord_message_url;
|
modalEl.discord_link.href = timelineItem.discord_message_url;
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
|
|
|
@ -60,6 +60,12 @@ type ConnOrTx interface {
|
||||||
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||||
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
|
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
|
||||||
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
|
Exec(ctx context.Context, sql string, args ...interface{}) (pgconn.CommandTag, error)
|
||||||
|
|
||||||
|
// Both raw database connections and transactions in pgx can begin/commit
|
||||||
|
// transactions. For database connections it does the obvious thing; for
|
||||||
|
// transactions it creates a "pseudo-nested transaction" but conceptually
|
||||||
|
// works the same. See the documentation of pgx.Tx.Begin.
|
||||||
|
Begin(ctx context.Context) (pgx.Tx, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
var connInfo = pgtype.NewConnInfo()
|
var connInfo = pgtype.NewConnInfo()
|
||||||
|
|
|
@ -64,7 +64,12 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message, is
|
||||||
}
|
}
|
||||||
|
|
||||||
if isJam {
|
if isJam {
|
||||||
_, err := tx.Exec(ctx, `UPDATE handmade_snippet SET is_jam = TRUE WHERE id = $1`, snippet.ID)
|
tagId, err := db.QueryInt(ctx, tx, `SELECT id FROM tags WHERE text = 'wheeljam'`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to fetch id of jam tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO snippet_tags (snippet_id, tag_id) VALUES ($1, $2)`, snippet.ID, tagId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to mark snippet as a jam snippet")
|
return oops.New(err, "failed to mark snippet as a jam snippet")
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,8 +6,9 @@ import (
|
||||||
"regexp"
|
"regexp"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -62,34 +63,31 @@ func GetBaseHost() string {
|
||||||
return baseUrlParsed.Host
|
return baseUrlParsed.Host
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UrlContext struct {
|
||||||
|
PersonalProject bool
|
||||||
|
ProjectID int
|
||||||
|
ProjectSlug string
|
||||||
|
ProjectName string
|
||||||
|
}
|
||||||
|
|
||||||
|
var HMNProjectContext = UrlContext{
|
||||||
|
PersonalProject: false,
|
||||||
|
ProjectID: models.HMNProjectID,
|
||||||
|
ProjectSlug: models.HMNProjectSlug,
|
||||||
|
}
|
||||||
|
|
||||||
func Url(path string, query []Q) string {
|
func Url(path string, query []Q) string {
|
||||||
return ProjectUrl(path, query, "")
|
return UrlWithFragment(path, query, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectUrl(path string, query []Q, slug string) string {
|
func UrlWithFragment(path string, query []Q, fragment string) string {
|
||||||
return ProjectUrlWithFragment(path, query, slug, "")
|
return HMNProjectContext.UrlWithFragment(path, query, fragment)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectUrlWithFragment(path string, query []Q, slug string, fragment string) string {
|
func (c *UrlContext) RewriteProjectUrl(u *url.URL) string {
|
||||||
subdomain := slug
|
// we need to strip anything matching the personal project regex to get the base path
|
||||||
if slug == models.HMNProjectSlug {
|
match := RegexPersonalProject.FindString(u.Path)
|
||||||
subdomain = ""
|
return c.Url(u.Path[len(match):], QFromURL(u))
|
||||||
}
|
|
||||||
|
|
||||||
host := baseUrlParsed.Host
|
|
||||||
if len(subdomain) > 0 {
|
|
||||||
host = slug + "." + host
|
|
||||||
}
|
|
||||||
|
|
||||||
url := url.URL{
|
|
||||||
Scheme: baseUrlParsed.Scheme,
|
|
||||||
Host: host,
|
|
||||||
Path: trim(path),
|
|
||||||
RawQuery: encodeQuery(query),
|
|
||||||
Fragment: fragment,
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.String()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func trim(path string) string {
|
func trim(path string) string {
|
||||||
|
|
|
@ -21,12 +21,11 @@ var RegexOldHome = regexp.MustCompile("^/home$")
|
||||||
var RegexHomepage = regexp.MustCompile("^/$")
|
var RegexHomepage = regexp.MustCompile("^/$")
|
||||||
|
|
||||||
func BuildHomepage() string {
|
func BuildHomepage() string {
|
||||||
return Url("/", nil)
|
return HMNProjectContext.BuildHomepage()
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildProjectHomepage(projectSlug string) string {
|
func (c *UrlContext) BuildHomepage() string {
|
||||||
defer CatchPanic()
|
return c.Url("/", nil)
|
||||||
return ProjectUrl("/", nil, projectSlug)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexShowcase = regexp.MustCompile("^/showcase$")
|
var RegexShowcase = regexp.MustCompile("^/showcase$")
|
||||||
|
@ -97,8 +96,6 @@ func BuildRegistrationSuccess() string {
|
||||||
return Url("/registered_successfully", nil)
|
return Url("/registered_successfully", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(asaf): Delete the old version a bit after launch
|
|
||||||
var RegexOldEmailConfirmation = regexp.MustCompile(`^/_register/confirm/(?P<username>[\w\ \.\,\-@\+\_]+)/(?P<hash>[\d\w]+)/(?P<nonce>.+)[\/]?$`)
|
|
||||||
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
|
var RegexEmailConfirmation = regexp.MustCompile("^/email_confirmation/(?P<username>[^/]+)/(?P<token>[^/]+)$")
|
||||||
|
|
||||||
func BuildEmailConfirmation(username, token string) string {
|
func BuildEmailConfirmation(username, token string) string {
|
||||||
|
@ -198,7 +195,7 @@ func BuildUserProfile(username string) string {
|
||||||
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
|
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
|
||||||
|
|
||||||
func BuildUserSettings(section string) string {
|
func BuildUserSettings(section string) string {
|
||||||
return ProjectUrlWithFragment("/settings", nil, "", section)
|
return UrlWithFragment("/settings", nil, section)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -295,20 +292,19 @@ func BuildProjectNew() string {
|
||||||
return Url("/projects/new", nil)
|
return Url("/projects/new", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectNotApproved = regexp.MustCompile("^/p/(?P<slug>.+)$")
|
var RegexPersonalProject = regexp.MustCompile("^/p/(?P<projectid>[0-9]+)(/(?P<projectslug>[a-zA-Z0-9-]+))?")
|
||||||
|
|
||||||
func BuildProjectNotApproved(slug string) string {
|
func BuildPersonalProject(id int, slug string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
|
return Url(fmt.Sprintf("/p/%d/%s", id, slug), nil)
|
||||||
return Url(fmt.Sprintf("/p/%s", slug), nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexProjectEdit = regexp.MustCompile("^/p/(?P<slug>.+)/edit$")
|
var RegexProjectEdit = regexp.MustCompile("^/edit$")
|
||||||
|
|
||||||
func BuildProjectEdit(slug string, section string) string {
|
func (c *UrlContext) BuildProjectEdit(section string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
|
|
||||||
return ProjectUrlWithFragment(fmt.Sprintf("/p/%s/edit", slug), nil, "", section)
|
return c.UrlWithFragment("/edit", nil, section)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -370,7 +366,50 @@ func BuildPodcastEpisodeFile(filename string) string {
|
||||||
// Make sure to match Thread before Subforum in the router.
|
// Make sure to match Thread before Subforum in the router.
|
||||||
var RegexForum = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
|
var RegexForum = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?(/(?P<page>\d+))?$`)
|
||||||
|
|
||||||
func BuildForum(projectSlug string, subforums []string, page int) string {
|
func (c *UrlContext) Url(path string, query []Q) string {
|
||||||
|
return c.UrlWithFragment(path, query, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UrlContext) UrlWithFragment(path string, query []Q, fragment string) string {
|
||||||
|
if c == nil {
|
||||||
|
logging.Warn().Stack().Msg("URL context was nil; defaulting to the HMN URL context")
|
||||||
|
c = &HMNProjectContext
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.PersonalProject {
|
||||||
|
url := url.URL{
|
||||||
|
Scheme: baseUrlParsed.Scheme,
|
||||||
|
Host: baseUrlParsed.Host,
|
||||||
|
Path: fmt.Sprintf("p/%d/%s/%s", c.ProjectID, models.GeneratePersonalProjectSlug(c.ProjectName), trim(path)),
|
||||||
|
RawQuery: encodeQuery(query),
|
||||||
|
Fragment: fragment,
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.String()
|
||||||
|
} else {
|
||||||
|
subdomain := c.ProjectSlug
|
||||||
|
if c.ProjectSlug == models.HMNProjectSlug {
|
||||||
|
subdomain = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
host := baseUrlParsed.Host
|
||||||
|
if len(subdomain) > 0 {
|
||||||
|
host = c.ProjectSlug + "." + host
|
||||||
|
}
|
||||||
|
|
||||||
|
url := url.URL{
|
||||||
|
Scheme: baseUrlParsed.Scheme,
|
||||||
|
Host: host,
|
||||||
|
Path: trim(path),
|
||||||
|
RawQuery: encodeQuery(query),
|
||||||
|
Fragment: fragment,
|
||||||
|
}
|
||||||
|
|
||||||
|
return url.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *UrlContext) BuildForum(subforums []string, page int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
|
panic(oops.New(nil, "Invalid forum thread page (%d), must be >= 1", page))
|
||||||
|
@ -383,13 +422,13 @@ func BuildForum(projectSlug string, subforums []string, page int) string {
|
||||||
builder.WriteString(strconv.Itoa(page))
|
builder.WriteString(strconv.Itoa(page))
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
|
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
|
||||||
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
|
var RegexForumNewThreadSubmit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new/submit$`)
|
||||||
|
|
||||||
func BuildForumNewThread(projectSlug string, subforums []string, submit bool) string {
|
func (c *UrlContext) BuildForumNewThread(subforums []string, submit bool) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildSubforumPath(subforums)
|
builder := buildSubforumPath(subforums)
|
||||||
builder.WriteString("/t/new")
|
builder.WriteString("/t/new")
|
||||||
|
@ -397,59 +436,59 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st
|
||||||
builder.WriteString("/submit")
|
builder.WriteString("/submit")
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
|
var RegexForumThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)(-([^/]+))?(/(?P<page>\d+))?$`)
|
||||||
|
|
||||||
func BuildForumThread(projectSlug string, subforums []string, threadId int, title string, page int) string {
|
func (c *UrlContext) BuildForumThread(subforums []string, threadId int, title string, page int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildForumThreadPath(subforums, threadId, title, page)
|
builder := buildForumThreadPath(subforums, threadId, title, page)
|
||||||
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildForumThreadWithPostHash(projectSlug string, subforums []string, threadId int, title string, page int, postId int) string {
|
func (c *UrlContext) BuildForumThreadWithPostHash(subforums []string, threadId int, title string, page int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildForumThreadPath(subforums, threadId, title, page)
|
builder := buildForumThreadPath(subforums, threadId, title, page)
|
||||||
|
|
||||||
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
|
return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId))
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
|
var RegexForumPost = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)$`)
|
||||||
|
|
||||||
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
|
func (c *UrlContext) BuildForumPost(subforums []string, threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildForumPostPath(subforums, threadId, postId)
|
builder := buildForumPostPath(subforums, threadId, postId)
|
||||||
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
|
var RegexForumPostDelete = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/delete$`)
|
||||||
|
|
||||||
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
|
func (c *UrlContext) BuildForumPostDelete(subforums []string, threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildForumPostPath(subforums, threadId, postId)
|
builder := buildForumPostPath(subforums, threadId, postId)
|
||||||
builder.WriteString("/delete")
|
builder.WriteString("/delete")
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
|
var RegexForumPostEdit = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/edit$`)
|
||||||
|
|
||||||
func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
|
func (c *UrlContext) BuildForumPostEdit(subforums []string, threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildForumPostPath(subforums, threadId, postId)
|
builder := buildForumPostPath(subforums, threadId, postId)
|
||||||
builder.WriteString("/edit")
|
builder.WriteString("/edit")
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
|
var RegexForumPostReply = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/(?P<threadid>\d+)/p/(?P<postid>\d+)/reply$`)
|
||||||
|
|
||||||
func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string {
|
func (c *UrlContext) BuildForumPostReply(subforums []string, threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildForumPostPath(subforums, threadId, postId)
|
builder := buildForumPostPath(subforums, threadId, postId)
|
||||||
builder.WriteString("/reply")
|
builder.WriteString("/reply")
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P<threadid>\d+)(-([^/]+))?$`)
|
var RegexWikiArticle = regexp.MustCompile(`^/wiki/(?P<threadid>\d+)(-([^/]+))?$`)
|
||||||
|
@ -462,7 +501,7 @@ var RegexBlogsRedirect = regexp.MustCompile(`^/blogs(?P<remainder>.*)`)
|
||||||
|
|
||||||
var RegexBlog = regexp.MustCompile(`^/blog(/(?P<page>\d+))?$`)
|
var RegexBlog = regexp.MustCompile(`^/blog(/(?P<page>\d+))?$`)
|
||||||
|
|
||||||
func BuildBlog(projectSlug string, page int) string {
|
func (c *UrlContext) BuildBlog(page int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
if page < 1 {
|
if page < 1 {
|
||||||
panic(oops.New(nil, "Invalid blog page (%d), must be >= 1", page))
|
panic(oops.New(nil, "Invalid blog page (%d), must be >= 1", page))
|
||||||
|
@ -473,63 +512,63 @@ func BuildBlog(projectSlug string, page int) string {
|
||||||
path += "/" + strconv.Itoa(page)
|
path += "/" + strconv.Itoa(page)
|
||||||
}
|
}
|
||||||
|
|
||||||
return ProjectUrl(path, nil, projectSlug)
|
return c.Url(path, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?$`)
|
var RegexBlogThread = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)(-([^/]+))?$`)
|
||||||
|
|
||||||
func BuildBlogThread(projectSlug string, threadId int, title string) string {
|
func (c *UrlContext) BuildBlogThread(threadId int, title string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildBlogThreadPath(threadId, title)
|
builder := buildBlogThreadPath(threadId, title)
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildBlogThreadWithPostHash(projectSlug string, threadId int, title string, postId int) string {
|
func (c *UrlContext) BuildBlogThreadWithPostHash(threadId int, title string, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildBlogThreadPath(threadId, title)
|
builder := buildBlogThreadPath(threadId, title)
|
||||||
return ProjectUrlWithFragment(builder.String(), nil, projectSlug, strconv.Itoa(postId))
|
return c.UrlWithFragment(builder.String(), nil, strconv.Itoa(postId))
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`)
|
var RegexBlogNewThread = regexp.MustCompile(`^/blog/new$`)
|
||||||
|
|
||||||
func BuildBlogNewThread(projectSlug string) string {
|
func (c *UrlContext) BuildBlogNewThread() string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return ProjectUrl("/blog/new", nil, projectSlug)
|
return c.Url("/blog/new", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)$`)
|
var RegexBlogPost = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)$`)
|
||||||
|
|
||||||
func BuildBlogPost(projectSlug string, threadId int, postId int) string {
|
func (c *UrlContext) BuildBlogPost(threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildBlogPostPath(threadId, postId)
|
builder := buildBlogPostPath(threadId, postId)
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexBlogPostDelete = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/delete$`)
|
var RegexBlogPostDelete = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/delete$`)
|
||||||
|
|
||||||
func BuildBlogPostDelete(projectSlug string, threadId int, postId int) string {
|
func (c *UrlContext) BuildBlogPostDelete(threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildBlogPostPath(threadId, postId)
|
builder := buildBlogPostPath(threadId, postId)
|
||||||
builder.WriteString("/delete")
|
builder.WriteString("/delete")
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexBlogPostEdit = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/edit$`)
|
var RegexBlogPostEdit = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/edit$`)
|
||||||
|
|
||||||
func BuildBlogPostEdit(projectSlug string, threadId int, postId int) string {
|
func (c *UrlContext) BuildBlogPostEdit(threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildBlogPostPath(threadId, postId)
|
builder := buildBlogPostPath(threadId, postId)
|
||||||
builder.WriteString("/edit")
|
builder.WriteString("/edit")
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexBlogPostReply = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/reply$`)
|
var RegexBlogPostReply = regexp.MustCompile(`^/blog/p/(?P<threadid>\d+)/e/(?P<postid>\d+)/reply$`)
|
||||||
|
|
||||||
func BuildBlogPostReply(projectSlug string, threadId int, postId int) string {
|
func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
builder := buildBlogPostPath(threadId, postId)
|
builder := buildBlogPostPath(threadId, postId)
|
||||||
builder.WriteString("/reply")
|
builder.WriteString("/reply")
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -583,7 +622,7 @@ func BuildLibraryResource(resourceId int) string {
|
||||||
|
|
||||||
var RegexEpisodeList = regexp.MustCompile(`^/episode(/(?P<topic>[^/]+))?$`)
|
var RegexEpisodeList = regexp.MustCompile(`^/episode(/(?P<topic>[^/]+))?$`)
|
||||||
|
|
||||||
func BuildEpisodeList(projectSlug string, topic string) string {
|
func (c *UrlContext) BuildEpisodeList(topic string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
|
|
||||||
var builder strings.Builder
|
var builder strings.Builder
|
||||||
|
@ -592,21 +631,21 @@ func BuildEpisodeList(projectSlug string, topic string) string {
|
||||||
builder.WriteString("/")
|
builder.WriteString("/")
|
||||||
builder.WriteString(topic)
|
builder.WriteString(topic)
|
||||||
}
|
}
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexEpisode = regexp.MustCompile(`^/episode/(?P<topic>[^/]+)/(?P<episode>[^/]+)$`)
|
var RegexEpisode = regexp.MustCompile(`^/episode/(?P<topic>[^/]+)/(?P<episode>[^/]+)$`)
|
||||||
|
|
||||||
func BuildEpisode(projectSlug string, topic string, episode string) string {
|
func (c *UrlContext) BuildEpisode(topic string, episode string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return ProjectUrl(fmt.Sprintf("/episode/%s/%s", topic, episode), nil, projectSlug)
|
return c.Url(fmt.Sprintf("/episode/%s/%s", topic, episode), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexCineraIndex = regexp.MustCompile(`^/(?P<topic>[^/]+).index$`)
|
var RegexCineraIndex = regexp.MustCompile(`^/(?P<topic>[^/]+).index$`)
|
||||||
|
|
||||||
func BuildCineraIndex(projectSlug string, topic string) string {
|
func (c *UrlContext) BuildCineraIndex(topic string) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
return ProjectUrl(fmt.Sprintf("/%s.index", topic), nil, projectSlug)
|
return c.Url(fmt.Sprintf("/%s.index", topic), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -638,8 +677,8 @@ func BuildDiscordShowcaseBacklog() string {
|
||||||
var RegexAssetUpload = regexp.MustCompile("^/upload_asset$")
|
var RegexAssetUpload = regexp.MustCompile("^/upload_asset$")
|
||||||
|
|
||||||
// NOTE(asaf): Providing the projectSlug avoids any CORS problems.
|
// NOTE(asaf): Providing the projectSlug avoids any CORS problems.
|
||||||
func BuildAssetUpload(projectSlug string) string {
|
func (c *UrlContext) BuildAssetUpload() string {
|
||||||
return ProjectUrl("/upload_asset", nil, projectSlug)
|
return c.Url("/upload_asset", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
@ -718,7 +757,7 @@ func BuildUserFile(filepath string) string {
|
||||||
var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
|
var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
|
||||||
|
|
||||||
// NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
|
// NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
|
||||||
func BuildForumMarkRead(projectSlug string, subforumId int) string {
|
func (c *UrlContext) BuildForumMarkRead(subforumId int) string {
|
||||||
defer CatchPanic()
|
defer CatchPanic()
|
||||||
if subforumId < 0 {
|
if subforumId < 0 {
|
||||||
panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId))
|
panic(oops.New(nil, "Invalid subforum ID (%d), must be >= 0", subforumId))
|
||||||
|
@ -728,10 +767,10 @@ func BuildForumMarkRead(projectSlug string, subforumId int) string {
|
||||||
builder.WriteString("/markread/")
|
builder.WriteString("/markread/")
|
||||||
builder.WriteString(strconv.Itoa(subforumId))
|
builder.WriteString(strconv.Itoa(subforumId))
|
||||||
|
|
||||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
return c.Url(builder.String(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
var RegexCatchAll = regexp.MustCompile("")
|
var RegexCatchAll = regexp.MustCompile("^")
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Helper functions
|
* Helper functions
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,68 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(CleanUpProjects{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type CleanUpProjects struct{}
|
||||||
|
|
||||||
|
func (m CleanUpProjects) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 11, 6, 3, 38, 35, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m CleanUpProjects) Name() string {
|
||||||
|
return "CleanUpProjects"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m CleanUpProjects) Description() string {
|
||||||
|
return "Clean up projects with data violating our new constraints"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m CleanUpProjects) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
DELETE FROM handmade_project WHERE id IN (91, 92);
|
||||||
|
DELETE FROM handmade_communicationchoicelist
|
||||||
|
WHERE project_id IN (91, 92);
|
||||||
|
DELETE FROM handmade_project_languages
|
||||||
|
WHERE project_id IN (91, 92);
|
||||||
|
DELETE FROM handmade_project_screenshots
|
||||||
|
WHERE project_id IN (91, 92);
|
||||||
|
|
||||||
|
UPDATE handmade_project
|
||||||
|
SET slug = 'hmh-notes'
|
||||||
|
WHERE slug = 'hmh_notes';
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to patch up project slugs")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m CleanUpProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
-- Don't bother restoring those old projects
|
||||||
|
|
||||||
|
UPDATE handmade_project
|
||||||
|
SET slug = 'hmh_notes'
|
||||||
|
WHERE slug = 'hmh-notes';
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to restore project slugs")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -0,0 +1,205 @@
|
||||||
|
package migrations
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
"github.com/jackc/pgx/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
registerMigration(PersonalProjects{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PersonalProjects struct{}
|
||||||
|
|
||||||
|
func (m PersonalProjects) Version() types.MigrationVersion {
|
||||||
|
return types.MigrationVersion(time.Date(2021, 11, 6, 3, 39, 30, 0, time.UTC))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m PersonalProjects) Name() string {
|
||||||
|
return "PersonalProjects"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m PersonalProjects) Description() string {
|
||||||
|
return "Add data model for personal projects / tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
CREATE TABLE tags (
|
||||||
|
id SERIAL NOT NULL PRIMARY KEY,
|
||||||
|
text VARCHAR(20) NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX tags_by_text ON tags (text);
|
||||||
|
|
||||||
|
ALTER TABLE tags
|
||||||
|
ADD CONSTRAINT tag_syntax CHECK (
|
||||||
|
text ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE snippet_tags (
|
||||||
|
snippet_id INT NOT NULL REFERENCES handmade_snippet (id) ON DELETE CASCADE,
|
||||||
|
tag_id INT NOT NULL REFERENCES tags (id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (snippet_id, tag_id)
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to add tags tables")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_snippet
|
||||||
|
DROP CONSTRAINT handmade_snippet_owner_id_fkey,
|
||||||
|
ALTER owner_id DROP NOT NULL,
|
||||||
|
ADD CONSTRAINT handmade_snippet_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE SET NULL;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to update snippet constraints")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
DROP featurevotes,
|
||||||
|
DROP parent_id,
|
||||||
|
DROP quota,
|
||||||
|
DROP quota_used,
|
||||||
|
DROP standalone,
|
||||||
|
ALTER flags TYPE BOOLEAN USING flags > 0;
|
||||||
|
|
||||||
|
ALTER TABLE handmade_project RENAME flags TO hidden;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to clean up existing fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
ADD personal BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
ADD tag INT REFERENCES tags (id);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to add new fields")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
ADD CONSTRAINT slug_syntax CHECK (
|
||||||
|
slug ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
|
||||||
|
);
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to add check constraints")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
UPDATE handmade_project
|
||||||
|
SET personal = FALSE;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to make existing projects official")
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
// Port "jam snippets" to use a tag
|
||||||
|
//
|
||||||
|
|
||||||
|
jamTagId, err := db.QueryInt(ctx, tx, `INSERT INTO tags (text) VALUES ('wheeljam') RETURNING id`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to create jam tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx,
|
||||||
|
`
|
||||||
|
INSERT INTO snippet_tags
|
||||||
|
SELECT id, $1
|
||||||
|
FROM handmade_snippet
|
||||||
|
WHERE is_jam
|
||||||
|
`,
|
||||||
|
jamTagId,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to add jam tag to jam snippets")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_snippet
|
||||||
|
DROP is_jam;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to drop is_jam column from snippets")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_snippet
|
||||||
|
ADD is_jam BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
UPDATE handmade_snippet
|
||||||
|
SET is_jam = TRUE
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT snippet.id
|
||||||
|
FROM
|
||||||
|
handmade_snippet AS snippet
|
||||||
|
JOIN snippet_tags ON snippet.id = snippet_tags.snippet_id
|
||||||
|
JOIN tags ON snippet_tags.tag_id = tags.id
|
||||||
|
WHERE
|
||||||
|
tags.text = 'wheeljam'
|
||||||
|
);
|
||||||
|
|
||||||
|
DELETE FROM tags WHERE text = 'wheeljam';
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to revert jam snippets")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_project
|
||||||
|
DROP CONSTRAINT slug_syntax,
|
||||||
|
DROP personal,
|
||||||
|
DROP tag,
|
||||||
|
ADD featurevotes INT NOT NULL DEFAULT 0,
|
||||||
|
-- no projects actually have a parent id so thankfully no further updates to do
|
||||||
|
ADD parent_id INT REFERENCES handmade_project (id) ON DELETE SET NULL,
|
||||||
|
ADD quota INT NOT NULL DEFAULT 0,
|
||||||
|
ADD quota_used INT NOT NULL DEFAULT 0,
|
||||||
|
ADD standalone BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
ALTER hidden TYPE INT USING CASE WHEN hidden THEN 1 ELSE 0 END;
|
||||||
|
|
||||||
|
ALTER TABLE handmade_project RENAME hidden TO flags;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to revert personal project changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
ALTER TABLE handmade_snippet
|
||||||
|
DROP CONSTRAINT handmade_snippet_owner_id_fkey,
|
||||||
|
ALTER owner_id SET NOT NULL,
|
||||||
|
ADD CONSTRAINT handmade_snippet_owner_id_fkey FOREIGN KEY (owner_id) REFERENCES auth_user (id) ON DELETE CASCADE;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to revert snippet constraint changes")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `
|
||||||
|
DROP TABLE snippet_tags;
|
||||||
|
DROP TABLE tags;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to drop tags table")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
|
@ -8,7 +8,7 @@ import (
|
||||||
"github.com/jackc/pgx/v4"
|
"github.com/jackc/pgx/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
var All map[types.MigrationVersion]types.Migration = make(map[types.MigrationVersion]types.Migration)
|
var All = make(map[types.MigrationVersion]types.Migration)
|
||||||
|
|
||||||
func registerMigration(m types.Migration) {
|
func registerMigration(m types.Migration) {
|
||||||
All[m.Version()] = m
|
All[m.Version()] = m
|
||||||
|
|
|
@ -2,6 +2,8 @@ package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -15,7 +17,7 @@ var ProjectType = reflect.TypeOf(Project{})
|
||||||
type ProjectLifecycle int
|
type ProjectLifecycle int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ProjectLifecycleUnapproved = iota
|
ProjectLifecycleUnapproved ProjectLifecycle = iota
|
||||||
ProjectLifecycleApprovalRequired
|
ProjectLifecycleApprovalRequired
|
||||||
ProjectLifecycleActive
|
ProjectLifecycleActive
|
||||||
ProjectLifecycleHiatus
|
ProjectLifecycleHiatus
|
||||||
|
@ -41,6 +43,7 @@ type Project struct {
|
||||||
|
|
||||||
Slug string `db:"slug"`
|
Slug string `db:"slug"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
|
TagID *int `db:"tag"`
|
||||||
Blurb string `db:"blurb"`
|
Blurb string `db:"blurb"`
|
||||||
Description string `db:"description"`
|
Description string `db:"description"`
|
||||||
ParsedDescription string `db:"descparsed"`
|
ParsedDescription string `db:"descparsed"`
|
||||||
|
@ -53,7 +56,8 @@ type Project struct {
|
||||||
LogoLight string `db:"logolight"`
|
LogoLight string `db:"logolight"`
|
||||||
LogoDark string `db:"logodark"`
|
LogoDark string `db:"logodark"`
|
||||||
|
|
||||||
Flags int `db:"flags"` // NOTE(asaf): Flags is currently only used to mark a project as hidden. Flags == 1 means hidden. Flags == 0 means visible.
|
Personal bool `db:"personal"`
|
||||||
|
Hidden bool `db:"hidden"`
|
||||||
Featured bool `db:"featured"`
|
Featured bool `db:"featured"`
|
||||||
DateApproved time.Time `db:"date_approved"`
|
DateApproved time.Time `db:"date_approved"`
|
||||||
AllLastUpdated time.Time `db:"all_last_updated"`
|
AllLastUpdated time.Time `db:"all_last_updated"`
|
||||||
|
@ -63,7 +67,7 @@ type Project struct {
|
||||||
|
|
||||||
ForumEnabled bool `db:"forum_enabled"`
|
ForumEnabled bool `db:"forum_enabled"`
|
||||||
BlogEnabled bool `db:"blog_enabled"`
|
BlogEnabled bool `db:"blog_enabled"`
|
||||||
LibraryEnabled bool `db:"library_enabled"`
|
LibraryEnabled bool `db:"library_enabled"` // TODO: Delete this field from the db
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Project) IsHMN() bool {
|
func (p *Project) IsHMN() bool {
|
||||||
|
@ -77,3 +81,30 @@ func (p *Project) Subdomain() string {
|
||||||
|
|
||||||
return p.Slug
|
return p.Slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Checks whether the project has forums enabled. This should restrict the creation of new forum
|
||||||
|
// content, but it should NOT prevent the viewing of existing forum content. (Projects may at one
|
||||||
|
// point have forums enabled, write some stuff, and then later disable forums, and we want that
|
||||||
|
// content to stay accessible.) Hiding the navigation is ok.
|
||||||
|
func (p *Project) HasForums() bool {
|
||||||
|
return !p.Personal && p.ForumEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same as HasForums, but for blogs.
|
||||||
|
func (p *Project) HasBlog() bool {
|
||||||
|
return !p.Personal && p.BlogEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var slugUnsafeChars = regexp.MustCompile(`[^a-zA-Z0-9-]`)
|
||||||
|
var slugHyphenRun = regexp.MustCompile(`-+`)
|
||||||
|
|
||||||
|
// Generates a URL-safe version of a personal project's name.
|
||||||
|
func GeneratePersonalProjectSlug(name string) string {
|
||||||
|
slug := name
|
||||||
|
slug = slugUnsafeChars.ReplaceAllLiteralString(slug, "-")
|
||||||
|
slug = slugHyphenRun.ReplaceAllLiteralString(slug, "-")
|
||||||
|
slug = strings.Trim(slug, "-")
|
||||||
|
slug = strings.ToLower(slug)
|
||||||
|
|
||||||
|
return slug
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1,18 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateSlug(t *testing.T) {
|
||||||
|
assert.Equal(t, "godspeed-you-black-emperor", GeneratePersonalProjectSlug("Godspeed You! Black Emperor"))
|
||||||
|
assert.Equal(t, "", GeneratePersonalProjectSlug("!@#$%^&"))
|
||||||
|
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("-- Foo Bar --"))
|
||||||
|
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("--foo-bar"))
|
||||||
|
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo--bar"))
|
||||||
|
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug("foo-bar--"))
|
||||||
|
assert.Equal(t, "foo-bar", GeneratePersonalProjectSlug(" Foo Bar "))
|
||||||
|
assert.Equal(t, "20-000-leagues-under-the-sea", GeneratePersonalProjectSlug("20,000 Leagues Under the Sea"))
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Text string `db:"text"`
|
||||||
|
}
|
|
@ -59,22 +59,11 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
|
||||||
models.ProjectLifecycleLTS: "Complete",
|
models.ProjectLifecycleLTS: "Complete",
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProjectUrl(p *models.Project) string {
|
func ProjectToTemplate(p *models.Project, url string, theme string) Project {
|
||||||
var url string
|
|
||||||
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
|
|
||||||
url = hmnurl.BuildProjectNotApproved(p.Slug)
|
|
||||||
} else {
|
|
||||||
url = hmnurl.BuildProjectHomepage(p.Slug)
|
|
||||||
}
|
|
||||||
return url
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProjectToTemplate(p *models.Project, theme string) Project {
|
|
||||||
logo := p.LogoLight
|
logo := p.LogoLight
|
||||||
if theme == "dark" {
|
if theme == "dark" {
|
||||||
logo = p.LogoDark
|
logo = p.LogoDark
|
||||||
}
|
}
|
||||||
url := ProjectUrl(p)
|
|
||||||
return Project{
|
return Project{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Subdomain: p.Subdomain(),
|
Subdomain: p.Subdomain(),
|
||||||
|
@ -91,9 +80,8 @@ func ProjectToTemplate(p *models.Project, theme string) Project {
|
||||||
|
|
||||||
IsHMN: p.IsHMN(),
|
IsHMN: p.IsHMN(),
|
||||||
|
|
||||||
HasBlog: p.BlogEnabled,
|
HasBlog: p.HasBlog(),
|
||||||
HasForum: p.ForumEnabled,
|
HasForum: p.HasForums(),
|
||||||
HasLibrary: false, // TODO: port the library lol
|
|
||||||
|
|
||||||
DateApproved: p.DateApproved,
|
DateApproved: p.DateApproved,
|
||||||
}
|
}
|
||||||
|
@ -319,7 +307,23 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
||||||
|
|
||||||
builder.WriteString(`"discord_message_url":"`)
|
builder.WriteString(`"discord_message_url":"`)
|
||||||
builder.WriteString(item.DiscordMessageUrl)
|
builder.WriteString(item.DiscordMessageUrl)
|
||||||
builder.WriteString(`"`)
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
|
builder.WriteString(`"tags":[`)
|
||||||
|
for _, tag := range item.Tags {
|
||||||
|
builder.WriteString(`{`)
|
||||||
|
|
||||||
|
builder.WriteString(`"text":"`)
|
||||||
|
builder.WriteString(tag.Text)
|
||||||
|
builder.WriteString(`",`)
|
||||||
|
|
||||||
|
builder.WriteString(`"url":"`)
|
||||||
|
builder.WriteString(tag.Url)
|
||||||
|
builder.WriteString(`"`)
|
||||||
|
|
||||||
|
builder.WriteString(`}`)
|
||||||
|
}
|
||||||
|
builder.WriteString(`]`)
|
||||||
|
|
||||||
builder.WriteRune('}')
|
builder.WriteRune('}')
|
||||||
}
|
}
|
||||||
|
@ -380,6 +384,13 @@ func DiscordUserToTemplate(d *models.DiscordUser) DiscordUser {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TagToTemplate(t *models.Tag) Tag {
|
||||||
|
return Tag{
|
||||||
|
Text: t.Text,
|
||||||
|
// TODO: Url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func maybeString(s *string) string {
|
func maybeString(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -20,7 +20,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ if $.User }}
|
{{ if and $.User $.Project.HasBlog }}
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||||
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||||
|
@ -81,7 +81,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{{ if $.User }}
|
{{ if and $.User $.Project.HasBlog }}
|
||||||
<div class="flex">
|
<div class="flex">
|
||||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||||
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||||
|
@ -111,10 +111,12 @@
|
||||||
|
|
||||||
<div class="optionbar bottom">
|
<div class="optionbar bottom">
|
||||||
<div class="options">
|
<div class="options">
|
||||||
{{ if $.User }}
|
{{ if .Project.HasBlog }}
|
||||||
<a class="button" href="{{ .ReplyLink }}"><span class="big pr1">+</span> Add Comment</a>
|
{{ if $.User }}
|
||||||
{{ else }}
|
<a class="button" href="{{ .ReplyLink }}"><span class="big pr1">+</span> Add Comment</a>
|
||||||
<a class="button" href="{{ .LoginLink }}">Log in to comment</a>
|
{{ else }}
|
||||||
|
<a class="button" href="{{ .LoginLink }}">Log in to comment</a>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -34,14 +34,18 @@
|
||||||
|
|
||||||
{{ define "subforum_options" }}
|
{{ define "subforum_options" }}
|
||||||
<div class="options">
|
<div class="options">
|
||||||
|
{{ if .Project.HasForum }}
|
||||||
|
{{ if .User }}
|
||||||
|
<a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big pr1">+</span> New Thread</a>
|
||||||
|
{{ else }}
|
||||||
|
<a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
{{ if .User }}
|
{{ if .User }}
|
||||||
<a class="button new-thread" href="{{ .NewThreadUrl }}"><span class="big pr1">+</span> New Thread</a>
|
|
||||||
<form method="POST" action="{{ .MarkReadUrl }}">
|
<form method="POST" action="{{ .MarkReadUrl }}">
|
||||||
{{ csrftoken .Session }}
|
{{ csrftoken .Session }}
|
||||||
<button type="submit"><span class="big pr1">✓</span> Mark threads here as read</button>
|
<button type="submit"><span class="big pr1">✓</span> Mark threads here as read</button>
|
||||||
</form>
|
</form>
|
||||||
{{ else }}
|
|
||||||
<a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="options">
|
<div class="options">
|
||||||
|
|
|
@ -61,7 +61,7 @@
|
||||||
<div class="postid">
|
<div class="postid">
|
||||||
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
|
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
|
||||||
</div>
|
</div>
|
||||||
{{ if $.User }}
|
{{ if and $.User $.Project.HasForum }}
|
||||||
<div class="flex pr3">
|
<div class="flex pr3">
|
||||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||||
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||||
|
@ -120,10 +120,12 @@
|
||||||
<a class="button" href="{{ .SubforumUrl }}">← Back to index</a>
|
<a class="button" href="{{ .SubforumUrl }}">← Back to index</a>
|
||||||
{{ if .Thread.Locked }}
|
{{ if .Thread.Locked }}
|
||||||
<span>Thread is locked.</span>
|
<span>Thread is locked.</span>
|
||||||
{{ else if .User }}
|
{{ else if .Project.HasForum }}
|
||||||
<a class="button" href="{{ .ReplyUrl }}">⤷ Reply to Thread</a>
|
{{ if .User }}
|
||||||
{{ else }}
|
<a class="button" href="{{ .ReplyUrl }}">⤷ Reply to Thread</a>
|
||||||
<span class="pa2"><a href="{{ .LoginPageUrl }}">Log in</a> to reply</span>
|
{{ else }}
|
||||||
|
<span class="pa2"><a href="{{ .LoginPageUrl }}">Log in</a> to reply</span>
|
||||||
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="options order-0 order-last-ns">
|
<div class="options order-0 order-last-ns">
|
||||||
|
|
|
@ -30,6 +30,7 @@
|
||||||
<div data-tmpl="description">
|
<div data-tmpl="description">
|
||||||
Unknown description
|
Unknown description
|
||||||
</div>
|
</div>
|
||||||
|
<div data-tmpl="tags" class="pt2 flex"></div>
|
||||||
<div class="i f7 pt2">
|
<div class="i f7 pt2">
|
||||||
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
|
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -38,3 +39,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<template id="timeline_item_tag">
|
||||||
|
<div data-tmpl="tag" class="bg-theme-dimmer ph2 pv1 br2"></div>
|
||||||
|
</template>
|
|
@ -59,4 +59,14 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with .Tags }}
|
||||||
|
<div class="mt3 flex">
|
||||||
|
{{ range $i, $tag := . }}
|
||||||
|
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
|
||||||
|
{{ $tag.Text }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -1,37 +1,28 @@
|
||||||
{{ template "base.html" . }}
|
{{ template "base.html" . }}
|
||||||
|
|
||||||
{{ define "content" }}
|
{{ define "content" }}
|
||||||
<div class="content-block no-bg-image">
|
<div>
|
||||||
{{ with .CarouselProjects }}
|
{{ with .CarouselProjects }}
|
||||||
<div class="carousel-container mw-100 mv2 mv3-ns margin-center">
|
<div class="carousel-container mw-100 mv2 mv3-ns margin-center dn db-ns">
|
||||||
<div class="carousel pa3 h5 overflow-hidden bg--dim br2-ns">
|
<div class="carousel pa3 h5 overflow-hidden bg--dim br2-ns">
|
||||||
<div class="dn db-l"> <!-- desktop carousel -->
|
{{ range $index, $project := . }}
|
||||||
{{ range $index, $project := . }}
|
<div class="carousel-item flex pa3 w-100 h-100 bg--dim items-center {{ if eq $index 0 }}active{{ end }}">
|
||||||
<div class="carousel-item flex pa3 w-100 h-100 bg--dim items-center {{ if eq $index 0 }}active{{ end }}">
|
<div class="flex-grow-1 pr3 relative flex flex-column h-100 justify-center">
|
||||||
<div class="flex-grow-1 pr3 relative flex flex-column h-100 justify-center">
|
<a href="{{ $project.Url }}">
|
||||||
<a href="{{ $project.Url }}">
|
<h3>{{ $project.Name }}</h3>
|
||||||
<h3>{{ $project.Name }}</h3>
|
</a>
|
||||||
</a>
|
<div class="carousel-description">
|
||||||
<div class="carousel-description">
|
{{ $project.ParsedDescription }}
|
||||||
{{ $project.ParsedDescription }}
|
|
||||||
</div>
|
|
||||||
<div class="carousel-fade"></div>
|
|
||||||
</div>
|
|
||||||
<div class="flex-shrink-0 order-0 order-1-ns">
|
|
||||||
<a href="{{ $project.Url }}">
|
|
||||||
<div class="image bg-center cover w5 h5 br2" style="background-image:url({{ $project.Logo }})" ></div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="carousel-fade"></div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
<div class="flex-shrink-0 order-0 order-1-ns">
|
||||||
</div>
|
<a href="{{ $project.Url }}">
|
||||||
<div class="db dn-l"> <!-- mobile/tablet carousel -->
|
<div class="image bg-center cover w5 h5 br2" style="background-image:url({{ $project.Logo }})" ></div>
|
||||||
{{ range $index, $project := . }}
|
</a>
|
||||||
<div class="carousel-item-small {{ if eq $index 0 }}active{{ end }}">
|
|
||||||
{{ template "project_card.html" projectcarddata $project "h-100" }}
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
</div>
|
||||||
</div>
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center pv2">
|
<div class="flex justify-center pv2">
|
||||||
{{ range $index, $project := . }}
|
{{ range $index, $project := . }}
|
||||||
|
@ -47,14 +38,18 @@
|
||||||
<div class="bg--dim-ns br2">
|
<div class="bg--dim-ns br2">
|
||||||
<div class="clear"></div>
|
<div class="clear"></div>
|
||||||
<div class="optionbar pv2 ph3">
|
<div class="optionbar pv2 ph3">
|
||||||
<a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed – New Projects</span></a>
|
<div class="options">
|
||||||
{{ template "pagination.html" .Pagination }}
|
<a href="{{ .ProjectAtomFeedUrl }}"><span class="icon big">4</span> RSS Feed – New Projects</span></a>
|
||||||
|
</div>
|
||||||
|
<div class="options">
|
||||||
|
{{ template "pagination.html" .Pagination }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="projectlist ph3">
|
<div class="projectlist ph3">
|
||||||
{{ range .Projects }}
|
{{ range .Projects }}
|
||||||
<div class="mv3">
|
<div class="mv3">
|
||||||
{{ template "project_card.html" projectcarddata . ""}}
|
{{ template "project_card.html" projectcarddata . "" }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -66,37 +61,11 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
|
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
|
||||||
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
|
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
|
||||||
{{ if not .UserPendingProject }}
|
<h2>Personal Projects</h2>
|
||||||
<div class="content-block new-project p-spaced">
|
<p>Many community members have projects of their own that are currently works in progress. Here's a few:</p>
|
||||||
<h2>Project submissions are closed</h2>
|
{{ range .PersonalProjects }}
|
||||||
<p>
|
<div class="mv3">
|
||||||
We are reworking the way we approach projects on the network. In the meantime feel free to share your work on the <a href="{{ .WIPForumUrl }}">forums</a> or on our <a href="https://discord.gg/hxWxDee">Discord</a>.
|
{{ template "project_card.html" projectcarddata . "" }}
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{{ else }}
|
|
||||||
<div class="content-block single">
|
|
||||||
<h2>Project pending</h2>
|
|
||||||
<p>Thanks for considering us as a home for<br /><a href="{{ .UserPendingProject.Url }}">{{ .UserPendingProject.Name }}</a>!</p>
|
|
||||||
<br />
|
|
||||||
{{ if .UserPendingProjectUnderReview }}
|
|
||||||
<p>We see it's ready for review by an administrator, great! We'll try and get back to you in a timely manner.</p>
|
|
||||||
{{ else }}
|
|
||||||
<p>When you're ready for us to review it, let us know using the checkbox on {{ .UserPendingProject.Name }}'s profile editor.</p>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
{{ if .UserApprovedProjects }}
|
|
||||||
<div class="content-block single projectlist">
|
|
||||||
{{ if .UserPendingProject }}
|
|
||||||
<h2>Your other projects</h2>
|
|
||||||
{{ else }}
|
|
||||||
<h2>Your projects</h2>
|
|
||||||
{{ end }}
|
|
||||||
{{ range .UserApprovedProjects }}
|
|
||||||
<div class="mv3">
|
|
||||||
{{ template "project_card.html" projectcarddata . "" }}
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -24,6 +24,15 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
{{ with .Snippet.Tags }}
|
||||||
|
<div class="mt3 flex">
|
||||||
|
{{ range $i, $tag := . }}
|
||||||
|
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
|
||||||
|
{{ $tag.Text }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
@ -126,9 +126,8 @@ type Project struct {
|
||||||
|
|
||||||
IsHMN bool
|
IsHMN bool
|
||||||
|
|
||||||
HasBlog bool
|
HasBlog bool
|
||||||
HasForum bool
|
HasForum bool
|
||||||
HasLibrary bool
|
|
||||||
|
|
||||||
UUID string
|
UUID string
|
||||||
DateApproved time.Time
|
DateApproved time.Time
|
||||||
|
@ -269,6 +268,7 @@ type TimelineItem struct {
|
||||||
OwnerName string
|
OwnerName string
|
||||||
OwnerUrl string
|
OwnerUrl string
|
||||||
|
|
||||||
|
Tags []Tag
|
||||||
Description template.HTML
|
Description template.HTML
|
||||||
|
|
||||||
PreviewMedia TimelineItemMedia
|
PreviewMedia TimelineItemMedia
|
||||||
|
@ -331,3 +331,8 @@ type DiscordUser struct {
|
||||||
Discriminator string
|
Discriminator string
|
||||||
Avatar string
|
Avatar string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
Text string
|
||||||
|
Url string
|
||||||
|
}
|
||||||
|
|
|
@ -137,7 +137,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
||||||
for _, p := range posts {
|
for _, p := range posts {
|
||||||
post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
|
post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
|
||||||
post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here
|
post.AddContentVersion(p.CurrentVersion, &p.Author) // NOTE(asaf): Don't care about editors here
|
||||||
post.Url = UrlForGenericPost(&p.Thread, &p.Post, lineageBuilder, p.Project.Slug)
|
post.Url = UrlForGenericPost(UrlContextForProject(&p.Project), &p.Thread, &p.Post, lineageBuilder)
|
||||||
data.Posts = append(data.Posts, postWithTitle{
|
data.Posts = append(data.Posts, postWithTitle{
|
||||||
Post: post,
|
Post: post,
|
||||||
Title: p.Thread.Title,
|
Title: p.Thread.Title,
|
||||||
|
|
|
@ -14,6 +14,11 @@ func getBaseDataAutocrumb(c *RequestContext, title string) templates.BaseData {
|
||||||
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
|
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
|
||||||
// If you pass nil, no breadcrumbs will be created.
|
// If you pass nil, no breadcrumbs will be created.
|
||||||
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
|
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
|
||||||
|
var project models.Project
|
||||||
|
if c.CurrentProject != nil {
|
||||||
|
project = *c.CurrentProject
|
||||||
|
}
|
||||||
|
|
||||||
var templateUser *templates.User
|
var templateUser *templates.User
|
||||||
var templateSession *templates.Session
|
var templateSession *templates.Session
|
||||||
if c.CurrentUser != nil {
|
if c.CurrentUser != nil {
|
||||||
|
@ -26,10 +31,10 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
notices := getNoticesFromCookie(c)
|
notices := getNoticesFromCookie(c)
|
||||||
|
|
||||||
if len(breadcrumbs) > 0 {
|
if len(breadcrumbs) > 0 {
|
||||||
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
|
projectUrl := c.UrlContext.BuildHomepage()
|
||||||
if breadcrumbs[0].Url != projectUrl {
|
if breadcrumbs[0].Url != projectUrl {
|
||||||
rootBreadcrumb := templates.Breadcrumb{
|
rootBreadcrumb := templates.Breadcrumb{
|
||||||
Name: c.CurrentProject.Name,
|
Name: project.Name,
|
||||||
Url: projectUrl,
|
Url: projectUrl,
|
||||||
}
|
}
|
||||||
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
|
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
|
||||||
|
@ -42,20 +47,20 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
Breadcrumbs: breadcrumbs,
|
Breadcrumbs: breadcrumbs,
|
||||||
|
|
||||||
CurrentUrl: c.FullUrl(),
|
CurrentUrl: c.FullUrl(),
|
||||||
CurrentProjectUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
|
CurrentProjectUrl: c.UrlContext.BuildHomepage(),
|
||||||
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
||||||
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
|
ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1),
|
||||||
|
|
||||||
Project: templates.ProjectToTemplate(c.CurrentProject, c.Theme),
|
Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage(), c.Theme),
|
||||||
User: templateUser,
|
User: templateUser,
|
||||||
Session: templateSession,
|
Session: templateSession,
|
||||||
Notices: notices,
|
Notices: notices,
|
||||||
|
|
||||||
ReportIssueMailto: "team@handmade.network",
|
ReportIssueMailto: "team@handmade.network",
|
||||||
|
|
||||||
OpenGraphItems: buildDefaultOpenGraphItems(c.CurrentProject, title),
|
OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
|
||||||
|
|
||||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
IsProjectPage: !project.IsHMN(),
|
||||||
Header: templates.Header{
|
Header: templates.Header{
|
||||||
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
|
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
|
||||||
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
||||||
|
@ -67,7 +72,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
HMNHomepageUrl: hmnurl.BuildHomepage(),
|
HMNHomepageUrl: hmnurl.BuildHomepage(),
|
||||||
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
||||||
PodcastUrl: hmnurl.BuildPodcast(),
|
PodcastUrl: hmnurl.BuildPodcast(),
|
||||||
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
|
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
|
||||||
LibraryUrl: hmnurl.BuildLibrary(),
|
LibraryUrl: hmnurl.BuildLibrary(),
|
||||||
},
|
},
|
||||||
Footer: templates.Footer{
|
Footer: templates.Footer{
|
||||||
|
@ -77,7 +82,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
|
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
|
||||||
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
|
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
|
||||||
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
||||||
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
|
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
|
||||||
ContactUrl: hmnurl.BuildContactPage(),
|
ContactUrl: hmnurl.BuildContactPage(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -86,19 +91,19 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
|
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.CurrentProject.IsHMN() {
|
if !project.IsHMN() {
|
||||||
episodeGuideUrl := ""
|
episodeGuideUrl := ""
|
||||||
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug]
|
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[project.Slug]
|
||||||
if hasAnnotations {
|
if hasAnnotations {
|
||||||
episodeGuideUrl = hmnurl.BuildEpisodeList(c.CurrentProject.Slug, defaultTopic)
|
episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic)
|
||||||
}
|
}
|
||||||
|
|
||||||
baseData.Header.Project = &templates.ProjectHeader{
|
baseData.Header.Project = &templates.ProjectHeader{
|
||||||
HasForums: c.CurrentProject.ForumEnabled,
|
HasForums: project.HasForums(),
|
||||||
HasBlog: c.CurrentProject.BlogEnabled,
|
HasBlog: project.HasBlog(),
|
||||||
HasEpisodeGuide: hasAnnotations,
|
HasEpisodeGuide: hasAnnotations,
|
||||||
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
|
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
|
||||||
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
|
BlogUrl: c.UrlContext.BuildBlog(1),
|
||||||
EpisodeGuideUrl: episodeGuideUrl,
|
EpisodeGuideUrl: episodeGuideUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,7 +35,7 @@ func BlogIndex(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
const postsPerPage = 5
|
const postsPerPage = 5
|
||||||
|
|
||||||
numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
|
numThreads, err := CountThreads(c.Context(), c.Conn, c.CurrentUser, ThreadsQuery{
|
||||||
ProjectIDs: []int{c.CurrentProject.ID},
|
ProjectIDs: []int{c.CurrentProject.ID},
|
||||||
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost},
|
||||||
})
|
})
|
||||||
|
@ -43,10 +43,10 @@ func BlogIndex(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch total number of blog posts"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch total number of blog posts"))
|
||||||
}
|
}
|
||||||
|
|
||||||
numPages := utils.NumPages(numPosts, postsPerPage)
|
numPages := utils.NumPages(numThreads, postsPerPage)
|
||||||
page, ok := ParsePageNumber(c, "page", numPages)
|
page, ok := ParsePageNumber(c, "page", numPages)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.Redirect(hmnurl.BuildBlog(c.CurrentProject.Slug, page), http.StatusSeeOther)
|
c.Redirect(c.UrlContext.BuildBlog(page), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
threads, err := FetchThreads(c.Context(), c.Conn, c.CurrentUser, ThreadsQuery{
|
threads, err := FetchThreads(c.Context(), c.Conn, c.CurrentUser, ThreadsQuery{
|
||||||
|
@ -63,19 +63,19 @@ func BlogIndex(c *RequestContext) ResponseData {
|
||||||
for _, thread := range threads {
|
for _, thread := range threads {
|
||||||
entries = append(entries, blogIndexEntry{
|
entries = append(entries, blogIndexEntry{
|
||||||
Title: thread.Thread.Title,
|
Title: thread.Thread.Title,
|
||||||
Url: hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.Thread.ID, thread.Thread.Title),
|
Url: c.UrlContext.BuildBlogThread(thread.Thread.ID, thread.Thread.Title),
|
||||||
Author: templates.UserToTemplate(thread.FirstPostAuthor, c.Theme),
|
Author: templates.UserToTemplate(thread.FirstPostAuthor, c.Theme),
|
||||||
Date: thread.FirstPost.PostDate,
|
Date: thread.FirstPost.PostDate,
|
||||||
Content: template.HTML(thread.FirstPostCurrentVersion.TextParsed),
|
Content: template.HTML(thread.FirstPostCurrentVersion.TextParsed),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
baseData := getBaseData(c, fmt.Sprintf("%s Blog", c.CurrentProject.Name), []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)})
|
baseData := getBaseData(c, fmt.Sprintf("%s Blog", c.CurrentProject.Name), []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)})
|
||||||
|
|
||||||
canCreate := false
|
canCreate := false
|
||||||
if c.CurrentUser != nil {
|
if c.CurrentProject.HasBlog() && c.CurrentUser != nil {
|
||||||
isProjectOwner := false
|
isProjectOwner := false
|
||||||
owners, err := FetchProjectOwners(c, c.CurrentProject.ID)
|
owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.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"))
|
||||||
}
|
}
|
||||||
|
@ -97,14 +97,14 @@ func BlogIndex(c *RequestContext) ResponseData {
|
||||||
Current: page,
|
Current: page,
|
||||||
Total: numPages,
|
Total: numPages,
|
||||||
|
|
||||||
FirstUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
|
FirstUrl: c.UrlContext.BuildBlog(1),
|
||||||
LastUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, numPages),
|
LastUrl: c.UrlContext.BuildBlog(numPages),
|
||||||
PreviousUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page-1, numPages)),
|
PreviousUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page-1, numPages)),
|
||||||
NextUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page+1, numPages)),
|
NextUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page+1, numPages)),
|
||||||
},
|
},
|
||||||
|
|
||||||
CanCreatePost: canCreate,
|
CanCreatePost: canCreate,
|
||||||
NewPostUrl: hmnurl.BuildBlogNewThread(c.CurrentProject.Slug),
|
NewPostUrl: c.UrlContext.BuildBlogNewThread(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
@ -138,11 +138,11 @@ func BlogThread(c *RequestContext) ResponseData {
|
||||||
for _, p := range posts {
|
for _, p := range posts {
|
||||||
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
||||||
post.AddContentVersion(p.CurrentVersion, p.Editor)
|
post.AddContentVersion(p.CurrentVersion, p.Editor)
|
||||||
addBlogUrlsToPost(&post, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
|
addBlogUrlsToPost(c.UrlContext, &post, &p.Thread, p.Post.ID)
|
||||||
|
|
||||||
if p.ReplyPost != nil {
|
if p.ReplyPost != nil {
|
||||||
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
|
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
|
||||||
addBlogUrlsToPost(&reply, c.CurrentProject.Slug, &p.Thread, p.Post.ID)
|
addBlogUrlsToPost(c.UrlContext, &reply, &p.Thread, p.Post.ID)
|
||||||
post.ReplyPost = &reply
|
post.ReplyPost = &reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -168,7 +168,7 @@ func BlogThread(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
baseData := getBaseData(c, thread.Title, []templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)})
|
baseData := getBaseData(c, thread.Title, []templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)})
|
||||||
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
|
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
|
||||||
Property: "og:description",
|
Property: "og:description",
|
||||||
Value: posts[0].Post.Preview,
|
Value: posts[0].Post.Preview,
|
||||||
|
@ -180,7 +180,7 @@ func BlogThread(c *RequestContext) ResponseData {
|
||||||
Thread: templates.ThreadToTemplate(&thread),
|
Thread: templates.ThreadToTemplate(&thread),
|
||||||
MainPost: templatePosts[0],
|
MainPost: templatePosts[0],
|
||||||
Comments: templatePosts[1:],
|
Comments: templatePosts[1:],
|
||||||
ReplyLink: hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, posts[0].Post.ID),
|
ReplyLink: c.UrlContext.BuildBlogPostReply(cd.ThreadID, posts[0].Post.ID),
|
||||||
LoginLink: hmnurl.BuildLoginPage(c.FullUrl()),
|
LoginLink: hmnurl.BuildLoginPage(c.FullUrl()),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
|
@ -202,7 +202,7 @@ func BlogPostRedirectToThread(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread for blog redirect"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread for blog redirect"))
|
||||||
}
|
}
|
||||||
|
|
||||||
threadUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, thread.Thread.Title, cd.PostID)
|
threadUrl := c.UrlContext.BuildBlogThreadWithPostHash(cd.ThreadID, thread.Thread.Title, cd.PostID)
|
||||||
return c.Redirect(threadUrl, http.StatusFound)
|
return c.Redirect(threadUrl, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,11 +210,11 @@ func BlogNewThread(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
fmt.Sprintf("Create New Post | %s", c.CurrentProject.Name),
|
fmt.Sprintf("Create New Post | %s", c.CurrentProject.Name),
|
||||||
[]templates.Breadcrumb{BlogBreadcrumb(c.CurrentProject.Slug)},
|
[]templates.Breadcrumb{BlogBreadcrumb(c.UrlContext)},
|
||||||
)
|
)
|
||||||
|
|
||||||
editData := getEditorDataForNew(c.CurrentUser, baseData, nil)
|
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
|
||||||
editData.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug)
|
editData.SubmitUrl = c.UrlContext.BuildBlogNewThread()
|
||||||
editData.SubmitLabel = "Create Post"
|
editData.SubmitLabel = "Create Post"
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
@ -268,7 +268,7 @@ func BlogNewThreadSubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new blog post"))
|
||||||
}
|
}
|
||||||
|
|
||||||
newThreadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, threadId, title)
|
newThreadUrl := c.UrlContext.BuildBlogThread(threadId, title)
|
||||||
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -301,11 +301,11 @@ func BlogPostEdit(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
title,
|
title,
|
||||||
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
|
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
|
||||||
)
|
)
|
||||||
|
|
||||||
editData := getEditorDataForEdit(c.CurrentUser, baseData, post)
|
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
|
||||||
editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
|
editData.SubmitUrl = c.UrlContext.BuildBlogPostEdit(cd.ThreadID, cd.PostID)
|
||||||
editData.SubmitLabel = "Submit Edited Post"
|
editData.SubmitLabel = "Submit Edited Post"
|
||||||
if post.Thread.FirstID != post.Post.ID {
|
if post.Thread.FirstID != post.Post.ID {
|
||||||
editData.SubmitLabel = "Submit Edited Comment"
|
editData.SubmitLabel = "Submit Edited Comment"
|
||||||
|
@ -373,7 +373,7 @@ func BlogPostEditSubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit blog post"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit blog post"))
|
||||||
}
|
}
|
||||||
|
|
||||||
postUrl := hmnurl.BuildBlogThreadWithPostHash(c.CurrentProject.Slug, cd.ThreadID, post.Thread.Title, cd.PostID)
|
postUrl := c.UrlContext.BuildBlogThreadWithPostHash(cd.ThreadID, post.Thread.Title, cd.PostID)
|
||||||
return c.Redirect(postUrl, http.StatusSeeOther)
|
return c.Redirect(postUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -396,14 +396,14 @@ func BlogPostReply(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
fmt.Sprintf("Replying to comment in \"%s\" | %s", post.Thread.Title, c.CurrentProject.Name),
|
fmt.Sprintf("Replying to comment in \"%s\" | %s", post.Thread.Title, c.CurrentProject.Name),
|
||||||
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
|
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
|
||||||
)
|
)
|
||||||
|
|
||||||
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
||||||
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
|
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
|
||||||
|
|
||||||
editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost)
|
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
|
||||||
editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
|
editData.SubmitUrl = c.UrlContext.BuildBlogPostReply(cd.ThreadID, cd.PostID)
|
||||||
editData.SubmitLabel = "Submit Reply"
|
editData.SubmitLabel = "Submit Reply"
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
@ -439,7 +439,7 @@ func BlogPostReplySubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to blog post"))
|
||||||
}
|
}
|
||||||
|
|
||||||
newPostUrl := hmnurl.BuildBlogPost(c.CurrentProject.Slug, cd.ThreadID, newPostId)
|
newPostUrl := c.UrlContext.BuildBlogPost(cd.ThreadID, newPostId)
|
||||||
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -472,7 +472,7 @@ func BlogPostDelete(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
title,
|
title,
|
||||||
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
|
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
|
||||||
)
|
)
|
||||||
|
|
||||||
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
||||||
|
@ -487,7 +487,7 @@ func BlogPostDelete(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{
|
res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
SubmitUrl: hmnurl.BuildBlogPostDelete(c.CurrentProject.Slug, cd.ThreadID, cd.PostID),
|
SubmitUrl: c.UrlContext.BuildBlogPostDelete(cd.ThreadID, cd.PostID),
|
||||||
Post: templatePost,
|
Post: templatePost,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
|
@ -517,8 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
if threadDeleted {
|
if threadDeleted {
|
||||||
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
|
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||||
return c.Redirect(projectUrl, http.StatusSeeOther)
|
|
||||||
} else {
|
} else {
|
||||||
thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{
|
thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{
|
||||||
ProjectIDs: []int{c.CurrentProject.ID},
|
ProjectIDs: []int{c.CurrentProject.ID},
|
||||||
|
@ -529,7 +528,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread after blog post delete"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch thread after blog post delete"))
|
||||||
}
|
}
|
||||||
threadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, thread.Thread.ID, thread.Thread.Title)
|
threadUrl := c.UrlContext.BuildBlogThread(thread.Thread.ID, thread.Thread.Title)
|
||||||
return c.Redirect(threadUrl, http.StatusSeeOther)
|
return c.Redirect(threadUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -608,9 +607,9 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
||||||
return res, true
|
return res, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func addBlogUrlsToPost(p *templates.Post, projectSlug string, thread *models.Thread, postId int) {
|
func addBlogUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, thread *models.Thread, postId int) {
|
||||||
p.Url = hmnurl.BuildBlogThreadWithPostHash(projectSlug, thread.ID, thread.Title, postId)
|
p.Url = urlContext.BuildBlogThreadWithPostHash(thread.ID, thread.Title, postId)
|
||||||
p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, thread.ID, postId)
|
p.DeleteUrl = urlContext.BuildBlogPostDelete(thread.ID, postId)
|
||||||
p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, thread.ID, postId)
|
p.EditUrl = urlContext.BuildBlogPostEdit(thread.ID, postId)
|
||||||
p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, thread.ID, postId)
|
p.ReplyUrl = urlContext.BuildBlogPostReply(thread.ID, postId)
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,58 +6,58 @@ import (
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb {
|
func ProjectBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
|
||||||
return templates.Breadcrumb{
|
return templates.Breadcrumb{
|
||||||
Name: project.Name,
|
Name: projectUrlContext.ProjectName,
|
||||||
Url: hmnurl.BuildProjectHomepage(project.Slug),
|
Url: projectUrlContext.BuildHomepage(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForumBreadcrumb(projectSlug string) templates.Breadcrumb {
|
func ForumBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
|
||||||
return templates.Breadcrumb{
|
return templates.Breadcrumb{
|
||||||
Name: "Forums",
|
Name: "Forums",
|
||||||
Url: hmnurl.BuildForum(projectSlug, nil, 1),
|
Url: projectUrlContext.BuildForum(nil, 1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func SubforumBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, subforumID int) []templates.Breadcrumb {
|
func SubforumBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, subforumID int) []templates.Breadcrumb {
|
||||||
var result []templates.Breadcrumb
|
var result []templates.Breadcrumb
|
||||||
result = []templates.Breadcrumb{
|
result = []templates.Breadcrumb{
|
||||||
ProjectBreadcrumb(project),
|
ProjectBreadcrumb(projectUrlContext),
|
||||||
ForumBreadcrumb(project.Slug),
|
ForumBreadcrumb(projectUrlContext),
|
||||||
}
|
}
|
||||||
subforums := lineageBuilder.GetSubforumLineage(subforumID)
|
subforums := lineageBuilder.GetSubforumLineage(subforumID)
|
||||||
slugs := lineageBuilder.GetSubforumLineageSlugs(subforumID)
|
slugs := lineageBuilder.GetSubforumLineageSlugs(subforumID)
|
||||||
for i, subforum := range subforums {
|
for i, subforum := range subforums {
|
||||||
result = append(result, templates.Breadcrumb{
|
result = append(result, templates.Breadcrumb{
|
||||||
Name: subforum.Name,
|
Name: subforum.Name,
|
||||||
Url: hmnurl.BuildForum(project.Slug, slugs[0:i+1], 1),
|
Url: projectUrlContext.BuildForum(slugs[0:i+1], 1),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func ForumThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
|
func ForumThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb {
|
||||||
result := SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID)
|
result := SubforumBreadcrumbs(projectUrlContext, lineageBuilder, *thread.SubforumID)
|
||||||
result = append(result, templates.Breadcrumb{
|
result = append(result, templates.Breadcrumb{
|
||||||
Name: thread.Title,
|
Name: thread.Title,
|
||||||
Url: hmnurl.BuildForumThread(project.Slug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1),
|
Url: projectUrlContext.BuildForumThread(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1),
|
||||||
})
|
})
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func BlogBreadcrumb(projectSlug string) templates.Breadcrumb {
|
func BlogBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
|
||||||
return templates.Breadcrumb{
|
return templates.Breadcrumb{
|
||||||
Name: "Blog",
|
Name: "Blog",
|
||||||
Url: hmnurl.BuildBlog(projectSlug, 1),
|
Url: projectUrlContext.BuildBlog(1),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func BlogThreadBreadcrumbs(projectSlug string, thread *models.Thread) []templates.Breadcrumb {
|
func BlogThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, thread *models.Thread) []templates.Breadcrumb {
|
||||||
result := []templates.Breadcrumb{
|
result := []templates.Breadcrumb{
|
||||||
BlogBreadcrumb(projectSlug),
|
BlogBreadcrumb(projectUrlContext),
|
||||||
{Name: thread.Title, Url: hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)},
|
{Name: thread.Title, Url: projectUrlContext.BuildBlogThread(thread.ID, thread.Title)},
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
|
@ -11,7 +11,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/config"
|
"git.handmade.network/hmn/hmn/src/config"
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -53,11 +52,11 @@ func EpisodeList(c *RequestContext) ResponseData {
|
||||||
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||||
|
|
||||||
if !hasEpisodeGuide {
|
if !hasEpisodeGuide {
|
||||||
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
if topic == "" {
|
if topic == "" {
|
||||||
return c.Redirect(hmnurl.BuildEpisodeList(slug, defaultTopic), http.StatusSeeOther)
|
return c.Redirect(c.UrlContext.BuildEpisodeList(defaultTopic), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
allTopics, foundTopic := topicsForProject(slug, topic)
|
allTopics, foundTopic := topicsForProject(slug, topic)
|
||||||
|
@ -82,7 +81,7 @@ func EpisodeList(c *RequestContext) ResponseData {
|
||||||
for _, t := range allTopics {
|
for _, t := range allTopics {
|
||||||
url := ""
|
url := ""
|
||||||
if t != foundTopic {
|
if t != foundTopic {
|
||||||
url = hmnurl.BuildEpisodeList(slug, t)
|
url = c.UrlContext.BuildEpisodeList(t)
|
||||||
}
|
}
|
||||||
topicLinks = append(topicLinks, templates.Link{LinkText: t, Url: url})
|
topicLinks = append(topicLinks, templates.Link{LinkText: t, Url: url})
|
||||||
}
|
}
|
||||||
|
@ -114,7 +113,7 @@ func Episode(c *RequestContext) ResponseData {
|
||||||
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||||
|
|
||||||
if !hasEpisodeGuide {
|
if !hasEpisodeGuide {
|
||||||
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, foundTopic := topicsForProject(slug, topic)
|
_, foundTopic := topicsForProject(slug, topic)
|
||||||
|
@ -150,7 +149,7 @@ func Episode(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
title,
|
title,
|
||||||
[]templates.Breadcrumb{{Name: "Episode Guide", Url: hmnurl.BuildEpisodeList(c.CurrentProject.Slug, foundTopic)}},
|
[]templates.Breadcrumb{{Name: "Episode Guide", Url: c.UrlContext.BuildEpisodeList(foundTopic)}},
|
||||||
)
|
)
|
||||||
res.MustWriteTemplate("episode.html", EpisodeData{
|
res.MustWriteTemplate("episode.html", EpisodeData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
|
|
|
@ -71,7 +71,7 @@ func Feed(c *RequestContext) ResponseData {
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
|
|
||||||
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
||||||
MarkAllReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, 0),
|
MarkAllReadUrl: c.UrlContext.BuildForumMarkRead(0),
|
||||||
Posts: posts,
|
Posts: posts,
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
|
@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
handmade_project AS project
|
handmade_project AS project
|
||||||
WHERE
|
WHERE
|
||||||
project.lifecycle = ANY($1)
|
project.lifecycle = ANY($1)
|
||||||
AND project.flags = 0
|
AND NOT project.hidden
|
||||||
ORDER BY date_approved DESC
|
ORDER BY date_approved DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`,
|
`,
|
||||||
|
@ -181,7 +181,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
projectMap := make(map[int]int) // map[project id]index in slice
|
projectMap := make(map[int]int) // map[project id]index in slice
|
||||||
for _, p := range projects.ToSlice() {
|
for _, p := range projects.ToSlice() {
|
||||||
project := p.(*projectResult).Project
|
project := p.(*projectResult).Project
|
||||||
templateProject := templates.ProjectToTemplate(&project, c.Theme)
|
templateProject := templates.ProjectToTemplate(&project, UrlContextForProject(&project).BuildHomepage(), c.Theme)
|
||||||
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
|
templateProject.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(templateProject.Url)).URN()
|
||||||
|
|
||||||
projectIds = append(projectIds, project.ID)
|
projectIds = append(projectIds, project.ID)
|
||||||
|
@ -228,35 +228,14 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
|
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
|
||||||
feedData.FeedUrl = hmnurl.BuildShowcase()
|
feedData.FeedUrl = hmnurl.BuildShowcase()
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
type snippetQuery struct {
|
Limit: itemsPerFeed,
|
||||||
Owner models.User `db:"owner"`
|
})
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
NOT snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
LIMIT $1
|
|
||||||
`,
|
|
||||||
itemsPerFeed,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
for _, s := range snippets {
|
||||||
for _, s := range snippetQuerySlice {
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
row := s.(*snippetQuery)
|
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
|
@ -35,8 +35,6 @@ type forumSubforumData struct {
|
||||||
TotalThreads int
|
TotalThreads int
|
||||||
}
|
}
|
||||||
|
|
||||||
type editActionType string
|
|
||||||
|
|
||||||
type editorData struct {
|
type editorData struct {
|
||||||
templates.BaseData
|
templates.BaseData
|
||||||
SubmitUrl string
|
SubmitUrl string
|
||||||
|
@ -54,13 +52,13 @@ type editorData struct {
|
||||||
UploadUrl string
|
UploadUrl string
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
|
func getEditorDataForNew(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, replyPost *templates.Post) editorData {
|
||||||
result := editorData{
|
result := editorData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
CanEditTitle: replyPost == nil,
|
CanEditTitle: replyPost == nil,
|
||||||
PostReplyingTo: replyPost,
|
PostReplyingTo: replyPost,
|
||||||
MaxFileSize: AssetMaxSize(currentUser),
|
MaxFileSize: AssetMaxSize(currentUser),
|
||||||
UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain),
|
UploadUrl: urlContext.BuildAssetUpload(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if replyPost != nil {
|
if replyPost != nil {
|
||||||
|
@ -70,7 +68,7 @@ func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData,
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData, p PostAndStuff) editorData {
|
func getEditorDataForEdit(urlContext *hmnurl.UrlContext, currentUser *models.User, baseData templates.BaseData, p PostAndStuff) editorData {
|
||||||
return editorData{
|
return editorData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
Title: p.Thread.Title,
|
Title: p.Thread.Title,
|
||||||
|
@ -78,7 +76,7 @@ func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData,
|
||||||
IsEditing: true,
|
IsEditing: true,
|
||||||
EditInitialContents: p.CurrentVersion.TextRaw,
|
EditInitialContents: p.CurrentVersion.TextRaw,
|
||||||
MaxFileSize: AssetMaxSize(currentUser),
|
MaxFileSize: AssetMaxSize(currentUser),
|
||||||
UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain),
|
UploadUrl: urlContext.BuildAssetUpload(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -104,7 +102,7 @@ func Forum(c *RequestContext) ResponseData {
|
||||||
numPages := utils.NumPages(numThreads, threadsPerPage)
|
numPages := utils.NumPages(numThreads, threadsPerPage)
|
||||||
page, ok := ParsePageNumber(c, "page", numPages)
|
page, ok := ParsePageNumber(c, "page", numPages)
|
||||||
if !ok {
|
if !ok {
|
||||||
c.Redirect(hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, page), http.StatusSeeOther)
|
c.Redirect(c.UrlContext.BuildForum(currentSubforumSlugs, page), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
howManyThreadsToSkip := (page - 1) * threadsPerPage
|
howManyThreadsToSkip := (page - 1) * threadsPerPage
|
||||||
|
|
||||||
|
@ -119,7 +117,7 @@ func Forum(c *RequestContext) ResponseData {
|
||||||
makeThreadListItem := func(row ThreadAndStuff) templates.ThreadListItem {
|
makeThreadListItem := func(row ThreadAndStuff) templates.ThreadListItem {
|
||||||
return templates.ThreadListItem{
|
return templates.ThreadListItem{
|
||||||
Title: row.Thread.Title,
|
Title: row.Thread.Title,
|
||||||
Url: hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
|
Url: c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(*row.Thread.SubforumID), row.Thread.ID, row.Thread.Title, 1),
|
||||||
FirstUser: templates.UserToTemplate(row.FirstPostAuthor, c.Theme),
|
FirstUser: templates.UserToTemplate(row.FirstPostAuthor, c.Theme),
|
||||||
FirstDate: row.FirstPost.PostDate,
|
FirstDate: row.FirstPost.PostDate,
|
||||||
LastUser: templates.UserToTemplate(row.LastPostAuthor, c.Theme),
|
LastUser: templates.UserToTemplate(row.LastPostAuthor, c.Theme),
|
||||||
|
@ -165,7 +163,7 @@ func Forum(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
subforums = append(subforums, forumSubforumData{
|
subforums = append(subforums, forumSubforumData{
|
||||||
Name: sfNode.Name,
|
Name: sfNode.Name,
|
||||||
Url: hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
|
Url: c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(sfNode.ID), 1),
|
||||||
Threads: threads,
|
Threads: threads,
|
||||||
TotalThreads: numThreads,
|
TotalThreads: numThreads,
|
||||||
})
|
})
|
||||||
|
@ -179,23 +177,23 @@ func Forum(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
fmt.Sprintf("%s Forums", c.CurrentProject.Name),
|
fmt.Sprintf("%s Forums", c.CurrentProject.Name),
|
||||||
SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID),
|
SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID),
|
||||||
)
|
)
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("forum.html", forumData{
|
res.MustWriteTemplate("forum.html", forumData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
|
NewThreadUrl: c.UrlContext.BuildForumNewThread(currentSubforumSlugs, false),
|
||||||
MarkReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, cd.SubforumID),
|
MarkReadUrl: c.UrlContext.BuildForumMarkRead(cd.SubforumID),
|
||||||
Threads: threads,
|
Threads: threads,
|
||||||
Pagination: templates.Pagination{
|
Pagination: templates.Pagination{
|
||||||
Current: page,
|
Current: page,
|
||||||
Total: numPages,
|
Total: numPages,
|
||||||
|
|
||||||
FirstUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
|
FirstUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
|
||||||
LastUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
|
LastUrl: c.UrlContext.BuildForum(currentSubforumSlugs, numPages),
|
||||||
NextUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
|
NextUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
|
||||||
PreviousUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
|
PreviousUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
|
||||||
},
|
},
|
||||||
Subforums: subforums,
|
Subforums: subforums,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
|
@ -308,7 +306,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
||||||
if sfId == 0 {
|
if sfId == 0 {
|
||||||
redirUrl = hmnurl.BuildFeed()
|
redirUrl = hmnurl.BuildFeed()
|
||||||
} else {
|
} else {
|
||||||
redirUrl = hmnurl.BuildForum(c.CurrentProject.Slug, lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
|
redirUrl = c.UrlContext.BuildForum(lineageBuilder.GetSubforumLineageSlugs(sfId), 1)
|
||||||
}
|
}
|
||||||
return c.Redirect(redirUrl, http.StatusSeeOther)
|
return c.Redirect(redirUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
@ -358,17 +356,17 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadPostsPerPage)
|
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadPostsPerPage)
|
||||||
if !ok {
|
if !ok {
|
||||||
urlNoPage := hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1)
|
urlNoPage := c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1)
|
||||||
return c.Redirect(urlNoPage, http.StatusSeeOther)
|
return c.Redirect(urlNoPage, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
pagination := templates.Pagination{
|
pagination := templates.Pagination{
|
||||||
Current: page,
|
Current: page,
|
||||||
Total: numPages,
|
Total: numPages,
|
||||||
|
|
||||||
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1),
|
FirstUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1),
|
||||||
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages),
|
LastUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, numPages),
|
||||||
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
|
NextUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page+1, numPages)),
|
||||||
PreviousUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
|
PreviousUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, utils.IntClamp(1, page-1, numPages)),
|
||||||
}
|
}
|
||||||
|
|
||||||
postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
|
postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
|
||||||
|
@ -385,11 +383,11 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
for _, p := range postsAndStuff {
|
for _, p := range postsAndStuff {
|
||||||
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
||||||
post.AddContentVersion(p.CurrentVersion, p.Editor)
|
post.AddContentVersion(p.CurrentVersion, p.Editor)
|
||||||
addForumUrlsToPost(&post, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, post.ID)
|
addForumUrlsToPost(c.UrlContext, &post, currentSubforumSlugs, thread.ID, post.ID)
|
||||||
|
|
||||||
if p.ReplyPost != nil {
|
if p.ReplyPost != nil {
|
||||||
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
|
reply := templates.PostToTemplate(p.ReplyPost, p.ReplyAuthor, c.Theme)
|
||||||
addForumUrlsToPost(&reply, c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, reply.ID)
|
addForumUrlsToPost(c.UrlContext, &reply, currentSubforumSlugs, thread.ID, reply.ID)
|
||||||
post.ReplyPost = &reply
|
post.ReplyPost = &reply
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -418,7 +416,7 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID))
|
baseData := getBaseData(c, thread.Title, SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
|
||||||
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
|
baseData.OpenGraphItems = append(baseData.OpenGraphItems, templates.OpenGraphItem{
|
||||||
Property: "og:description",
|
Property: "og:description",
|
||||||
Value: threadResult.FirstPost.Preview,
|
Value: threadResult.FirstPost.Preview,
|
||||||
|
@ -429,8 +427,8 @@ func ForumThread(c *RequestContext) ResponseData {
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
Thread: templates.ThreadToTemplate(&thread),
|
Thread: templates.ThreadToTemplate(&thread),
|
||||||
Posts: posts,
|
Posts: posts,
|
||||||
SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
|
SubforumUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
|
||||||
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.FirstID),
|
ReplyUrl: c.UrlContext.BuildForumPostReply(currentSubforumSlugs, thread.ID, thread.FirstID),
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
|
@ -466,8 +464,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
|
||||||
|
|
||||||
page := (postIdx / threadPostsPerPage) + 1
|
page := (postIdx / threadPostsPerPage) + 1
|
||||||
|
|
||||||
return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
|
return c.Redirect(c.UrlContext.BuildForumThreadWithPostHash(
|
||||||
c.CurrentProject.Slug,
|
|
||||||
cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
|
cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
|
||||||
cd.ThreadID,
|
cd.ThreadID,
|
||||||
post.Thread.Title,
|
post.Thread.Title,
|
||||||
|
@ -482,9 +479,9 @@ func ForumNewThread(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID))
|
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
|
||||||
editData := getEditorDataForNew(c.CurrentUser, baseData, nil)
|
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
|
||||||
editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
|
editData.SubmitUrl = c.UrlContext.BuildForumNewThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
|
||||||
editData.SubmitLabel = "Post New Thread"
|
editData.SubmitLabel = "Post New Thread"
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
@ -549,7 +546,7 @@ func ForumNewThreadSubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to create new forum thread"))
|
||||||
}
|
}
|
||||||
|
|
||||||
newThreadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
|
newThreadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), threadId, title, 1)
|
||||||
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -572,14 +569,14 @@ func ForumPostReply(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name),
|
fmt.Sprintf("Replying to post | %s", cd.SubforumTree[cd.SubforumID].Name),
|
||||||
ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread),
|
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
|
||||||
)
|
)
|
||||||
|
|
||||||
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
replyPost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
||||||
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
|
replyPost.AddContentVersion(post.CurrentVersion, post.Editor)
|
||||||
|
|
||||||
editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost)
|
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
|
||||||
editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||||
editData.SubmitLabel = "Submit Reply"
|
editData.SubmitLabel = "Submit Reply"
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
@ -629,7 +626,7 @@ func ForumPostReplySubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to reply to forum post"))
|
||||||
}
|
}
|
||||||
|
|
||||||
newPostUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
|
newPostUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, newPostId)
|
||||||
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -659,10 +656,10 @@ func ForumPostEdit(c *RequestContext) ResponseData {
|
||||||
} else {
|
} else {
|
||||||
title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name)
|
title = fmt.Sprintf("Editing Post | %s", cd.SubforumTree[cd.SubforumID].Name)
|
||||||
}
|
}
|
||||||
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread))
|
baseData := getBaseData(c, title, ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread))
|
||||||
|
|
||||||
editData := getEditorDataForEdit(c.CurrentUser, baseData, post)
|
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
|
||||||
editData.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||||
editData.SubmitLabel = "Submit Edited Post"
|
editData.SubmitLabel = "Submit Edited Post"
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
|
@ -727,7 +724,7 @@ func ForumPostEditSubmit(c *RequestContext) ResponseData {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to edit forum post"))
|
||||||
}
|
}
|
||||||
|
|
||||||
postUrl := hmnurl.BuildForumPost(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
postUrl := c.UrlContext.BuildForumPost(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||||
return c.Redirect(postUrl, http.StatusSeeOther)
|
return c.Redirect(postUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -754,7 +751,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
|
||||||
baseData := getBaseData(
|
baseData := getBaseData(
|
||||||
c,
|
c,
|
||||||
fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[cd.SubforumID].Name),
|
fmt.Sprintf("Deleting post in \"%s\" | %s", post.Thread.Title, cd.SubforumTree[cd.SubforumID].Name),
|
||||||
ForumThreadBreadcrumbs(cd.LineageBuilder, c.CurrentProject, &post.Thread),
|
ForumThreadBreadcrumbs(c.UrlContext, cd.LineageBuilder, &post.Thread),
|
||||||
)
|
)
|
||||||
|
|
||||||
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
||||||
|
@ -769,7 +766,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
|
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
|
||||||
BaseData: baseData,
|
BaseData: baseData,
|
||||||
SubmitUrl: hmnurl.BuildForumPostDelete(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
|
SubmitUrl: c.UrlContext.BuildForumPostDelete(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID),
|
||||||
Post: templatePost,
|
Post: templatePost,
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
|
@ -799,10 +796,10 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
if threadDeleted {
|
if threadDeleted {
|
||||||
forumUrl := hmnurl.BuildForum(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
|
forumUrl := c.UrlContext.BuildForum(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), 1)
|
||||||
return c.Redirect(forumUrl, http.StatusSeeOther)
|
return c.Redirect(forumUrl, http.StatusSeeOther)
|
||||||
} else {
|
} else {
|
||||||
threadUrl := hmnurl.BuildForumThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
|
threadUrl := c.UrlContext.BuildForumThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, "", 1) // TODO: Go to the last page of the thread? Or the post before the post we just deleted?
|
||||||
return c.Redirect(threadUrl, http.StatusSeeOther)
|
return c.Redirect(threadUrl, http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -829,7 +826,7 @@ func WikiArticleRedirect(c *RequestContext) ResponseData {
|
||||||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
dest := UrlForGenericThread(&thread.Thread, lineageBuilder, c.CurrentProject.Slug)
|
dest := UrlForGenericThread(c.UrlContext, &thread.Thread, lineageBuilder)
|
||||||
return c.Redirect(dest, http.StatusFound)
|
return c.Redirect(dest, http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -928,11 +925,11 @@ func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *m
|
||||||
return subforumId, valid
|
return subforumId, valid
|
||||||
}
|
}
|
||||||
|
|
||||||
func addForumUrlsToPost(p *templates.Post, projectSlug string, subforums []string, threadId int, postId int) {
|
func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subforums []string, threadId int, postId int) {
|
||||||
p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId)
|
p.Url = urlContext.BuildForumPost(subforums, threadId, postId)
|
||||||
p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId)
|
p.DeleteUrl = urlContext.BuildForumPostDelete(subforums, threadId, postId)
|
||||||
p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId)
|
p.EditUrl = urlContext.BuildForumPostEdit(subforums, threadId, postId)
|
||||||
p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId)
|
p.ReplyUrl = urlContext.BuildForumPostReply(subforums, threadId, postId)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Takes a template post and adds information about how many posts the user has made
|
// Takes a template post and adds information about how many posts the user has made
|
||||||
|
|
|
@ -4,9 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
@ -20,35 +18,25 @@ func JamIndex(c *RequestContext) ResponseData {
|
||||||
daysUntil = 0
|
daysUntil = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
tagId := -1
|
||||||
type snippetQuery struct {
|
jamTag, err := FetchTag(c.Context(), c.Conn, TagQuery{
|
||||||
Owner models.User `db:"owner"`
|
Text: []string{"wheeljam"},
|
||||||
Snippet models.Snippet `db:"snippet"`
|
})
|
||||||
Asset *models.Asset `db:"asset"`
|
if err == nil {
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
tagId = jamTag.ID
|
||||||
|
} else {
|
||||||
|
c.Logger.Warn().Err(err).Msg("failed to fetch jam tag; will fetch all snippets as a result")
|
||||||
}
|
}
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
SELECT $columns
|
Tags: []int{tagId},
|
||||||
FROM
|
})
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
LIMIT 20
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
for _, s := range snippets {
|
||||||
for _, s := range snippetQuerySlice {
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
row := s.(*snippetQuery)
|
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
if timelineItem.CanShowcase {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
@ -74,7 +73,7 @@ func Index(c *RequestContext) ResponseData {
|
||||||
c.Logger.Warn().Err(err).Msg("failed to fetch latest posts")
|
c.Logger.Warn().Err(err).Msg("failed to fetch latest posts")
|
||||||
}
|
}
|
||||||
for _, p := range posts {
|
for _, p := range posts {
|
||||||
item := PostToTimelineItem(lineageBuilder, &p.Post, &p.Thread, &p.Project, p.Author, c.Theme)
|
item := PostToTimelineItem(UrlContextForProject(&p.Project), lineageBuilder, &p.Post, &p.Thread, p.Author, c.Theme)
|
||||||
if p.Thread.Type == models.ThreadTypeProjectBlogPost && p.Post.ID == p.Thread.FirstID {
|
if p.Thread.Type == models.ThreadTypeProjectBlogPost && p.Post.ID == p.Thread.FirstID {
|
||||||
// blog post
|
// blog post
|
||||||
item.Description = template.HTML(p.CurrentVersion.TextParsed)
|
item.Description = template.HTML(p.CurrentVersion.TextParsed)
|
||||||
|
@ -95,7 +94,7 @@ func Index(c *RequestContext) ResponseData {
|
||||||
var newsPostItem *templates.TimelineItem
|
var newsPostItem *templates.TimelineItem
|
||||||
if len(newsThreads) > 0 {
|
if len(newsThreads) > 0 {
|
||||||
t := newsThreads[0]
|
t := newsThreads[0]
|
||||||
item := PostToTimelineItem(lineageBuilder, &t.FirstPost, &t.Thread, &t.Project, t.FirstPostAuthor, c.Theme)
|
item := PostToTimelineItem(UrlContextForProject(&t.Project), lineageBuilder, &t.FirstPost, &t.Thread, t.FirstPostAuthor, c.Theme)
|
||||||
item.OwnerAvatarUrl = ""
|
item.OwnerAvatarUrl = ""
|
||||||
item.Breadcrumbs = nil
|
item.Breadcrumbs = nil
|
||||||
item.TypeTitle = ""
|
item.TypeTitle = ""
|
||||||
|
@ -106,35 +105,15 @@ func Index(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
type snippetQuery struct {
|
Limit: 40,
|
||||||
Owner models.User `db:"owner"`
|
})
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
NOT snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
LIMIT 40
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
for _, s := range snippets {
|
||||||
for _, s := range snippetQuerySlice {
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
row := s.(*snippetQuery)
|
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
if timelineItem.CanShowcase {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
@ -167,7 +146,7 @@ func Index(c *RequestContext) ResponseData {
|
||||||
StreamsUrl: hmnurl.BuildStreams(),
|
StreamsUrl: hmnurl.BuildStreams(),
|
||||||
ShowcaseUrl: hmnurl.BuildShowcase(),
|
ShowcaseUrl: hmnurl.BuildShowcase(),
|
||||||
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
||||||
MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0),
|
MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0),
|
||||||
|
|
||||||
WheelJamUrl: hmnurl.BuildJamIndex(),
|
WheelJamUrl: hmnurl.BuildJamIndex(),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
|
|
|
@ -7,26 +7,26 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
// NOTE(asaf): Please don't use these if you already know the kind of the thread beforehand. Just call the appropriate build function.
|
// NOTE(asaf): Please don't use these if you already know the kind of the thread beforehand. Just call the appropriate build function.
|
||||||
func UrlForGenericThread(thread *models.Thread, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
|
func UrlForGenericThread(urlContext *hmnurl.UrlContext, thread *models.Thread, lineageBuilder *models.SubforumLineageBuilder) string {
|
||||||
switch thread.Type {
|
switch thread.Type {
|
||||||
case models.ThreadTypeProjectBlogPost:
|
case models.ThreadTypeProjectBlogPost:
|
||||||
return hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)
|
return urlContext.BuildBlogThread(thread.ID, thread.Title)
|
||||||
case models.ThreadTypeForumPost:
|
case models.ThreadTypeForumPost:
|
||||||
return hmnurl.BuildForumThread(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1)
|
return urlContext.BuildForumThread(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), thread.ID, thread.Title, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
return urlContext.BuildHomepage()
|
||||||
}
|
}
|
||||||
|
|
||||||
func UrlForGenericPost(thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder, projectSlug string) string {
|
func UrlForGenericPost(urlContext *hmnurl.UrlContext, thread *models.Thread, post *models.Post, lineageBuilder *models.SubforumLineageBuilder) string {
|
||||||
switch post.ThreadType {
|
switch post.ThreadType {
|
||||||
case models.ThreadTypeProjectBlogPost:
|
case models.ThreadTypeProjectBlogPost:
|
||||||
return hmnurl.BuildBlogThreadWithPostHash(projectSlug, post.ThreadID, thread.Title, post.ID)
|
return urlContext.BuildBlogThreadWithPostHash(post.ThreadID, thread.Title, post.ID)
|
||||||
case models.ThreadTypeForumPost:
|
case models.ThreadTypeForumPost:
|
||||||
return hmnurl.BuildForumPost(projectSlug, lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
|
return urlContext.BuildForumPost(lineageBuilder.GetSubforumLineageSlugs(*thread.SubforumID), post.ThreadID, post.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
return urlContext.BuildHomepage()
|
||||||
}
|
}
|
||||||
|
|
||||||
var PostTypeMap = map[models.ThreadType][]templates.PostType{
|
var PostTypeMap = map[models.ThreadType][]templates.PostType{
|
||||||
|
@ -47,33 +47,33 @@ var ThreadTypeDisplayNames = map[models.ThreadType]string{
|
||||||
models.ThreadTypeForumPost: "Forums",
|
models.ThreadTypeForumPost: "Forums",
|
||||||
}
|
}
|
||||||
|
|
||||||
func GenericThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
|
func GenericThreadBreadcrumbs(urlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb {
|
||||||
var result []templates.Breadcrumb
|
var result []templates.Breadcrumb
|
||||||
if thread.Type == models.ThreadTypeForumPost {
|
if thread.Type == models.ThreadTypeForumPost {
|
||||||
result = SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID)
|
result = SubforumBreadcrumbs(urlContext, lineageBuilder, *thread.SubforumID)
|
||||||
} else {
|
} else {
|
||||||
result = []templates.Breadcrumb{
|
result = []templates.Breadcrumb{
|
||||||
{
|
{
|
||||||
Name: project.Name,
|
Name: urlContext.ProjectName,
|
||||||
Url: hmnurl.BuildProjectHomepage(project.Slug),
|
Url: urlContext.BuildHomepage(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Name: ThreadTypeDisplayNames[thread.Type],
|
Name: ThreadTypeDisplayNames[thread.Type],
|
||||||
Url: BuildProjectRootResourceUrl(project.Slug, thread.Type),
|
Url: BuildProjectRootResourceUrl(urlContext, thread.Type),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
|
func BuildProjectRootResourceUrl(urlContext *hmnurl.UrlContext, kind models.ThreadType) string {
|
||||||
switch kind {
|
switch kind {
|
||||||
case models.ThreadTypeProjectBlogPost:
|
case models.ThreadTypeProjectBlogPost:
|
||||||
return hmnurl.BuildBlog(projectSlug, 1)
|
return urlContext.BuildBlog(1)
|
||||||
case models.ThreadTypeForumPost:
|
case models.ThreadTypeForumPost:
|
||||||
return hmnurl.BuildForum(projectSlug, nil, 1)
|
return urlContext.BuildForum(nil, 1)
|
||||||
}
|
}
|
||||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
return urlContext.BuildHomepage()
|
||||||
}
|
}
|
||||||
|
|
||||||
func MakePostListItem(
|
func MakePostListItem(
|
||||||
|
@ -88,11 +88,13 @@ func MakePostListItem(
|
||||||
) templates.PostListItem {
|
) templates.PostListItem {
|
||||||
var result templates.PostListItem
|
var result templates.PostListItem
|
||||||
|
|
||||||
|
urlContext := UrlContextForProject(project)
|
||||||
|
|
||||||
result.Title = thread.Title
|
result.Title = thread.Title
|
||||||
result.User = templates.UserToTemplate(user, currentTheme)
|
result.User = templates.UserToTemplate(user, currentTheme)
|
||||||
result.Date = post.PostDate
|
result.Date = post.PostDate
|
||||||
result.Unread = unread
|
result.Unread = unread
|
||||||
result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug)
|
result.Url = UrlForGenericPost(urlContext, thread, post, lineageBuilder)
|
||||||
result.Preview = post.Preview
|
result.Preview = post.Preview
|
||||||
|
|
||||||
postType := templates.PostTypeUnknown
|
postType := templates.PostTypeUnknown
|
||||||
|
@ -108,7 +110,7 @@ func MakePostListItem(
|
||||||
result.PostTypePrefix = PostTypePrefix[result.PostType]
|
result.PostTypePrefix = PostTypePrefix[result.PostType]
|
||||||
|
|
||||||
if includeBreadcrumbs {
|
if includeBreadcrumbs {
|
||||||
result.Breadcrumbs = GenericThreadBreadcrumbs(lineageBuilder, project, thread)
|
result.Breadcrumbs = GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
|
@ -1,17 +1,246 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
|
|
||||||
"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"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type ProjectTypeQuery int
|
||||||
|
|
||||||
|
const (
|
||||||
|
PersonalProjects ProjectTypeQuery = 1 << iota
|
||||||
|
OfficialProjects
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectsQuery struct {
|
||||||
|
// Available on all project queries
|
||||||
|
Lifecycles []models.ProjectLifecycle // if empty, defaults to models.VisibleProjectLifecycles
|
||||||
|
Types ProjectTypeQuery // bitfield
|
||||||
|
IncludeHidden bool
|
||||||
|
|
||||||
|
// Ignored when using FetchProject
|
||||||
|
ProjectIDs []int // if empty, all projects
|
||||||
|
Slugs []string // if empty, all projects
|
||||||
|
|
||||||
|
// Ignored when using CountProjects
|
||||||
|
Limit, Offset int // if empty, no pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectAndStuff struct {
|
||||||
|
Project models.Project
|
||||||
|
Owners []*models.User
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchProjects(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
currentUser *models.User,
|
||||||
|
q ProjectsQuery,
|
||||||
|
) ([]ProjectAndStuff, error) {
|
||||||
|
perf := ExtractPerf(ctx)
|
||||||
|
perf.StartBlock("SQL", "Fetch projects")
|
||||||
|
defer perf.EndBlock()
|
||||||
|
|
||||||
|
var currentUserID *int
|
||||||
|
if currentUser != nil {
|
||||||
|
currentUserID = ¤tUser.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := dbConn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to start transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Fetch all valid projects (not yet subject to user permission checks)
|
||||||
|
var qb db.QueryBuilder
|
||||||
|
qb.Add(`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_project AS project
|
||||||
|
WHERE
|
||||||
|
TRUE
|
||||||
|
`)
|
||||||
|
if !q.IncludeHidden {
|
||||||
|
qb.Add(`AND NOT hidden`)
|
||||||
|
}
|
||||||
|
if len(q.ProjectIDs) > 0 {
|
||||||
|
qb.Add(`AND project.id = ANY ($?)`, q.ProjectIDs)
|
||||||
|
}
|
||||||
|
if len(q.Slugs) > 0 {
|
||||||
|
qb.Add(`AND project.slug = ANY ($?)`, q.Slugs)
|
||||||
|
}
|
||||||
|
if len(q.Lifecycles) > 0 {
|
||||||
|
qb.Add(`AND project.lifecycle = ANY($?)`, q.Lifecycles)
|
||||||
|
} else {
|
||||||
|
qb.Add(`AND project.lifecycle = ANY($?)`, models.VisibleProjectLifecycles)
|
||||||
|
}
|
||||||
|
if q.Types != 0 {
|
||||||
|
qb.Add(`AND (FALSE`)
|
||||||
|
if q.Types&PersonalProjects != 0 {
|
||||||
|
qb.Add(`OR project.personal`)
|
||||||
|
}
|
||||||
|
if q.Types&OfficialProjects != 0 {
|
||||||
|
qb.Add(`OR NOT project.personal`)
|
||||||
|
}
|
||||||
|
qb.Add(`)`)
|
||||||
|
}
|
||||||
|
if q.Limit > 0 {
|
||||||
|
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||||
|
}
|
||||||
|
itProjects, err := db.Query(ctx, dbConn, models.Project{}, qb.String(), qb.Args()...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to fetch projects")
|
||||||
|
}
|
||||||
|
iprojects := itProjects.ToSlice()
|
||||||
|
|
||||||
|
// Fetch project owners to do permission checks
|
||||||
|
projectIds := make([]int, len(iprojects))
|
||||||
|
for i, iproject := range iprojects {
|
||||||
|
projectIds[i] = iproject.(*models.Project).ID
|
||||||
|
}
|
||||||
|
projectOwners, err := FetchMultipleProjectsOwners(ctx, tx, projectIds)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var res []ProjectAndStuff
|
||||||
|
for i, iproject := range iprojects {
|
||||||
|
project := iproject.(*models.Project)
|
||||||
|
owners := projectOwners[i].Owners
|
||||||
|
|
||||||
|
/*
|
||||||
|
Per our spec, a user can see a project if:
|
||||||
|
- The project is official
|
||||||
|
- The project is personal and all of the project's owners are approved
|
||||||
|
- The project is personal and the current user is a collaborator (regardless of user status)
|
||||||
|
|
||||||
|
See https://www.notion.so/handmade-network/Technical-Plan-a11aaa9ea2f14d9a95f7d7780edd789c
|
||||||
|
*/
|
||||||
|
|
||||||
|
var projectVisible bool
|
||||||
|
if project.Personal {
|
||||||
|
allOwnersApproved := true
|
||||||
|
for _, owner := range owners {
|
||||||
|
if owner.Status != models.UserStatusApproved {
|
||||||
|
allOwnersApproved = false
|
||||||
|
}
|
||||||
|
if currentUserID != nil && *currentUserID == owner.ID {
|
||||||
|
projectVisible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allOwnersApproved {
|
||||||
|
projectVisible = true
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
projectVisible = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectVisible {
|
||||||
|
res = append(res, ProjectAndStuff{
|
||||||
|
Project: *project,
|
||||||
|
Owners: owners,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to commit transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fetches a single project. A wrapper around FetchProjects.
|
||||||
|
|
||||||
|
Returns db.NotFound if no result is found.
|
||||||
|
*/
|
||||||
|
func FetchProject(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
currentUser *models.User,
|
||||||
|
projectID int,
|
||||||
|
q ProjectsQuery,
|
||||||
|
) (ProjectAndStuff, error) {
|
||||||
|
q.ProjectIDs = []int{projectID}
|
||||||
|
q.Limit = 1
|
||||||
|
q.Offset = 0
|
||||||
|
|
||||||
|
res, err := FetchProjects(ctx, dbConn, currentUser, q)
|
||||||
|
if err != nil {
|
||||||
|
return ProjectAndStuff{}, oops.New(err, "failed to fetch project")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res) == 0 {
|
||||||
|
return ProjectAndStuff{}, db.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return res[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fetches a single project by slug. A wrapper around FetchProjects.
|
||||||
|
|
||||||
|
Returns db.NotFound if no result is found.
|
||||||
|
*/
|
||||||
|
func FetchProjectBySlug(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
currentUser *models.User,
|
||||||
|
projectSlug string,
|
||||||
|
q ProjectsQuery,
|
||||||
|
) (ProjectAndStuff, error) {
|
||||||
|
q.Slugs = []string{projectSlug}
|
||||||
|
q.Limit = 1
|
||||||
|
q.Offset = 0
|
||||||
|
|
||||||
|
res, err := FetchProjects(ctx, dbConn, currentUser, q)
|
||||||
|
if err != nil {
|
||||||
|
return ProjectAndStuff{}, oops.New(err, "failed to fetch project")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res) == 0 {
|
||||||
|
return ProjectAndStuff{}, db.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return res[0], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func CountProjects(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
currentUser *models.User,
|
||||||
|
q ProjectsQuery,
|
||||||
|
) (int, error) {
|
||||||
|
perf := ExtractPerf(ctx)
|
||||||
|
perf.StartBlock("SQL", "Count projects")
|
||||||
|
defer perf.EndBlock()
|
||||||
|
|
||||||
|
q.Limit = 0
|
||||||
|
q.Offset = 0
|
||||||
|
|
||||||
|
// I'm lazy and there probably won't ever be that many projects.
|
||||||
|
projects, err := FetchProjects(ctx, dbConn, currentUser, q)
|
||||||
|
if err != nil {
|
||||||
|
return 0, oops.New(err, "failed to fetch projects")
|
||||||
|
}
|
||||||
|
|
||||||
|
return len(projects), nil
|
||||||
|
}
|
||||||
|
|
||||||
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
|
func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool, error) {
|
||||||
if user != nil {
|
if user != nil {
|
||||||
if user.IsStaff {
|
if user.IsStaff {
|
||||||
return true, nil
|
return true, nil
|
||||||
} else {
|
} else {
|
||||||
owners, err := FetchProjectOwners(c, projectId)
|
owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
@ -25,29 +254,146 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool,
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) {
|
type ProjectOwners struct {
|
||||||
var result []*models.User
|
ProjectID int
|
||||||
c.Perf.StartBlock("SQL", "Fetching project owners")
|
Owners []*models.User
|
||||||
type ownerQuery struct {
|
}
|
||||||
Owner models.User `db:"auth_user"`
|
|
||||||
|
// Fetches all owners for multiple projects. Does NOT check permissions on the
|
||||||
|
// project IDs, since the assumption is that you will check permissions on the
|
||||||
|
// projects themselves before using any of this data.
|
||||||
|
//
|
||||||
|
// The returned slice will always have one entry for each project ID given, in
|
||||||
|
// the same order as they were provided. If there are duplicate project IDs in
|
||||||
|
// projectIds, the results will be wrong, so don't do that.
|
||||||
|
//
|
||||||
|
// This function does not verify that the requested projects do in fact exist.
|
||||||
|
func FetchMultipleProjectsOwners(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
projectIds []int,
|
||||||
|
) ([]ProjectOwners, error) {
|
||||||
|
perf := ExtractPerf(ctx)
|
||||||
|
perf.StartBlock("SQL", "Fetch owners for multiple projects")
|
||||||
|
defer perf.EndBlock()
|
||||||
|
|
||||||
|
tx, err := dbConn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to start transaction")
|
||||||
}
|
}
|
||||||
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
// Fetch all user/project pairs for the given projects
|
||||||
|
type userProject struct {
|
||||||
|
UserID int `db:"user_id"`
|
||||||
|
ProjectID int `db:"project_id"`
|
||||||
|
}
|
||||||
|
it, err := db.Query(ctx, tx, userProject{},
|
||||||
`
|
`
|
||||||
SELECT $columns
|
SELECT $columns
|
||||||
FROM
|
FROM handmade_user_projects
|
||||||
auth_user
|
WHERE project_id = ANY($1)
|
||||||
INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id
|
|
||||||
WHERE
|
|
||||||
uproj.project_id = $1
|
|
||||||
`,
|
`,
|
||||||
projectId,
|
projectIds,
|
||||||
)
|
)
|
||||||
c.Perf.EndBlock()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, oops.New(err, "failed to fetch owners for project")
|
return nil, oops.New(err, "failed to fetch project IDs")
|
||||||
}
|
}
|
||||||
for _, ownerRow := range ownerQueryResult.ToSlice() {
|
iuserprojects := it.ToSlice()
|
||||||
result = append(result, &ownerRow.(*ownerQuery).Owner)
|
|
||||||
|
// Get the unique user IDs from this set and fetch the users from the db
|
||||||
|
var userIds []int
|
||||||
|
for _, iuserproject := range iuserprojects {
|
||||||
|
userProject := iuserproject.(*userProject)
|
||||||
|
|
||||||
|
addUserId := true
|
||||||
|
for _, uid := range userIds {
|
||||||
|
if uid == userProject.UserID {
|
||||||
|
addUserId = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addUserId {
|
||||||
|
userIds = append(userIds, userProject.UserID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
it, err = db.Query(ctx, tx, models.User{},
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM auth_user
|
||||||
|
WHERE
|
||||||
|
id = ANY($1)
|
||||||
|
`,
|
||||||
|
userIds,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to fetch users for projects")
|
||||||
|
}
|
||||||
|
iusers := it.ToSlice()
|
||||||
|
|
||||||
|
// Build the final result set with real user data
|
||||||
|
res := make([]ProjectOwners, len(projectIds))
|
||||||
|
for i, pid := range projectIds {
|
||||||
|
res[i] = ProjectOwners{ProjectID: pid}
|
||||||
|
}
|
||||||
|
for _, iuserproject := range iuserprojects {
|
||||||
|
userProject := iuserproject.(*userProject)
|
||||||
|
|
||||||
|
// Get a pointer to the existing record in the result
|
||||||
|
var projectOwners *ProjectOwners
|
||||||
|
for i := range res {
|
||||||
|
if res[i].ProjectID == userProject.ProjectID {
|
||||||
|
projectOwners = &res[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the full user record we fetched
|
||||||
|
var user *models.User
|
||||||
|
for _, iuser := range iusers {
|
||||||
|
u := iuser.(*models.User)
|
||||||
|
if u.ID == userProject.UserID {
|
||||||
|
user = u
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if user == nil {
|
||||||
|
panic("we apparently failed to fetch a project's owner")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slam 'em together
|
||||||
|
projectOwners.Owners = append(projectOwners.Owners, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to commit transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetches project owners for a single project. It is subject to all the same
|
||||||
|
// restrictions as FetchMultipleProjectsOwners.
|
||||||
|
func FetchProjectOwners(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
projectId int,
|
||||||
|
) ([]*models.User, error) {
|
||||||
|
perf := ExtractPerf(ctx)
|
||||||
|
perf.StartBlock("SQL", "Fetch owners for project")
|
||||||
|
defer perf.EndBlock()
|
||||||
|
|
||||||
|
projectOwners, err := FetchMultipleProjectsOwners(ctx, dbConn, []int{projectId})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return projectOwners[0].Owners, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func UrlContextForProject(p *models.Project) *hmnurl.UrlContext {
|
||||||
|
return &hmnurl.UrlContext{
|
||||||
|
PersonalProject: p.Personal,
|
||||||
|
ProjectID: p.ID,
|
||||||
|
ProjectSlug: p.Slug,
|
||||||
|
ProjectName: p.Name,
|
||||||
}
|
}
|
||||||
return result, nil
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
package website
|
package website
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"sort"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
@ -24,10 +22,7 @@ type ProjectTemplateData struct {
|
||||||
Pagination templates.Pagination
|
Pagination templates.Pagination
|
||||||
CarouselProjects []templates.Project
|
CarouselProjects []templates.Project
|
||||||
Projects []templates.Project
|
Projects []templates.Project
|
||||||
|
PersonalProjects []templates.Project
|
||||||
UserPendingProjectUnderReview bool
|
|
||||||
UserPendingProject *templates.Project
|
|
||||||
UserApprovedProjects []templates.Project
|
|
||||||
|
|
||||||
ProjectAtomFeedUrl string
|
ProjectAtomFeedUrl string
|
||||||
WIPForumUrl string
|
WIPForumUrl string
|
||||||
|
@ -36,47 +31,19 @@ type ProjectTemplateData struct {
|
||||||
func ProjectIndex(c *RequestContext) ResponseData {
|
func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
const projectsPerPage = 20
|
const projectsPerPage = 20
|
||||||
const maxCarouselProjects = 10
|
const maxCarouselProjects = 10
|
||||||
|
const maxPersonalProjects = 10
|
||||||
|
|
||||||
page := 1
|
officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
|
||||||
pageString, hasPage := c.PathParams["page"]
|
Types: OfficialProjects,
|
||||||
if hasPage && pageString != "" {
|
})
|
||||||
if pageParsed, err := strconv.Atoi(pageString); err == nil {
|
|
||||||
page = pageParsed
|
|
||||||
} else {
|
|
||||||
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if page < 1 {
|
|
||||||
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetching all visible projects")
|
|
||||||
type projectResult struct {
|
|
||||||
Project models.Project `db:"project"`
|
|
||||||
}
|
|
||||||
allProjects, err := db.Query(c.Context(), c.Conn, projectResult{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_project AS project
|
|
||||||
WHERE
|
|
||||||
project.lifecycle = ANY($1)
|
|
||||||
AND project.flags = 0
|
|
||||||
ORDER BY project.date_approved ASC
|
|
||||||
`,
|
|
||||||
models.VisibleProjectLifecycles,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects"))
|
||||||
}
|
}
|
||||||
allProjectsSlice := allProjects.ToSlice()
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
|
|
||||||
numPages := int(math.Ceil(float64(len(allProjectsSlice)) / projectsPerPage))
|
numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage))
|
||||||
|
page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage)
|
||||||
if page > numPages {
|
if !ok {
|
||||||
return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther)
|
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
|
||||||
}
|
}
|
||||||
|
|
||||||
pagination := templates.Pagination{
|
pagination := templates.Pagination{
|
||||||
|
@ -89,63 +56,22 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)),
|
PreviousUrl: hmnurl.BuildProjectIndex(utils.IntClamp(1, page-1, numPages)),
|
||||||
}
|
}
|
||||||
|
|
||||||
var userApprovedProjects []templates.Project
|
|
||||||
var userPendingProject *templates.Project
|
|
||||||
userPendingProjectUnderReview := false
|
|
||||||
if c.CurrentUser != nil {
|
|
||||||
c.Perf.StartBlock("SQL", "fetching user projects")
|
|
||||||
type UserProjectQuery struct {
|
|
||||||
Project models.Project `db:"project"`
|
|
||||||
}
|
|
||||||
userProjectsResult, err := db.Query(c.Context(), c.Conn, UserProjectQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_project AS project
|
|
||||||
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
|
|
||||||
WHERE
|
|
||||||
uproj.user_id = $1
|
|
||||||
`,
|
|
||||||
c.CurrentUser.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user projects"))
|
|
||||||
}
|
|
||||||
for _, project := range userProjectsResult.ToSlice() {
|
|
||||||
p := project.(*UserProjectQuery).Project
|
|
||||||
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
|
|
||||||
if userPendingProject == nil {
|
|
||||||
// NOTE(asaf): Technically a user could have more than one pending project.
|
|
||||||
// For example, if they created one project themselves and were added as an additional owner to another user's project.
|
|
||||||
// So we'll just take the first one. I don't think it matters. I guess it especially won't matter after Projects 2.0.
|
|
||||||
tmplProject := templates.ProjectToTemplate(&p, c.Theme)
|
|
||||||
userPendingProject = &tmplProject
|
|
||||||
userPendingProjectUnderReview = (p.Lifecycle == models.ProjectLifecycleApprovalRequired)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
userApprovedProjects = append(userApprovedProjects, templates.ProjectToTemplate(&p, c.Theme))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("PROJECTS", "Grouping and sorting")
|
c.Perf.StartBlock("PROJECTS", "Grouping and sorting")
|
||||||
var handmadeHero *templates.Project
|
var handmadeHero *templates.Project
|
||||||
var featuredProjects []templates.Project
|
var featuredProjects []templates.Project
|
||||||
var recentProjects []templates.Project
|
var recentProjects []templates.Project
|
||||||
var restProjects []templates.Project
|
var restProjects []templates.Project
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
for _, p := range allProjectsSlice {
|
for _, p := range officialProjects {
|
||||||
project := &p.(*projectResult).Project
|
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||||
templateProject := templates.ProjectToTemplate(project, c.Theme)
|
if p.Project.Slug == "hero" {
|
||||||
if 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
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if project.Featured {
|
if p.Project.Featured {
|
||||||
featuredProjects = append(featuredProjects, templateProject)
|
featuredProjects = append(featuredProjects, templateProject)
|
||||||
} else if now.Sub(project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
|
} else if now.Sub(p.Project.AllLastUpdated).Seconds() < models.RecentProjectUpdateTimespanSec {
|
||||||
recentProjects = append(recentProjects, templateProject)
|
recentProjects = append(recentProjects, templateProject)
|
||||||
} else {
|
} else {
|
||||||
restProjects = append(restProjects, templateProject)
|
restProjects = append(restProjects, templateProject)
|
||||||
|
@ -178,6 +104,32 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
// Fetch and highlight a random selection of personal projects
|
||||||
|
var personalProjects []templates.Project
|
||||||
|
{
|
||||||
|
projects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
|
||||||
|
Types: PersonalProjects,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal projects"))
|
||||||
|
}
|
||||||
|
|
||||||
|
randSeed := now.YearDay()
|
||||||
|
random := rand.New(rand.NewSource(int64(randSeed)))
|
||||||
|
random.Shuffle(len(projects), func(i, j int) { projects[i], projects[j] = projects[j], projects[i] })
|
||||||
|
|
||||||
|
for i, p := range projects {
|
||||||
|
if i >= maxPersonalProjects {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
personalProjects = append(personalProjects, templates.ProjectToTemplate(
|
||||||
|
&p.Project,
|
||||||
|
UrlContextForProject(&p.Project).BuildHomepage(),
|
||||||
|
c.Theme,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
baseData := getBaseDataAutocrumb(c, "Projects")
|
baseData := getBaseDataAutocrumb(c, "Projects")
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
|
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
|
||||||
|
@ -186,13 +138,10 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
CarouselProjects: carouselProjects,
|
CarouselProjects: carouselProjects,
|
||||||
Projects: pageProjects,
|
Projects: pageProjects,
|
||||||
|
PersonalProjects: personalProjects,
|
||||||
UserPendingProjectUnderReview: userPendingProjectUnderReview,
|
|
||||||
UserPendingProject: userPendingProject,
|
|
||||||
UserApprovedProjects: userApprovedProjects,
|
|
||||||
|
|
||||||
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
|
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
|
||||||
WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1),
|
WIPForumUrl: hmnurl.HMNProjectContext.BuildForum([]string{"wip"}, 1),
|
||||||
}, c.Perf)
|
}, c.Perf)
|
||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
@ -209,86 +158,19 @@ type ProjectHomepageData struct {
|
||||||
|
|
||||||
func ProjectHomepage(c *RequestContext) ResponseData {
|
func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
maxRecentActivity := 15
|
maxRecentActivity := 15
|
||||||
var project *models.Project
|
|
||||||
|
|
||||||
if c.CurrentProject.IsHMN() {
|
if c.CurrentProject == nil {
|
||||||
slug, hasSlug := c.PathParams["slug"]
|
|
||||||
if hasSlug && slug != "" {
|
|
||||||
slug = strings.ToLower(slug)
|
|
||||||
if slug == models.HMNProjectSlug {
|
|
||||||
return c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
c.Perf.StartBlock("SQL", "Fetching project by slug")
|
|
||||||
type projectQuery struct {
|
|
||||||
Project models.Project `db:"Project"`
|
|
||||||
}
|
|
||||||
projectQueryResult, err := db.QueryOne(c.Context(), c.Conn, projectQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_project AS project
|
|
||||||
WHERE
|
|
||||||
LOWER(project.slug) = $1
|
|
||||||
`,
|
|
||||||
slug,
|
|
||||||
)
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, db.NotFound) {
|
|
||||||
return FourOhFour(c)
|
|
||||||
} else {
|
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project by slug"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
project = &projectQueryResult.(*projectQuery).Project
|
|
||||||
if project.Lifecycle != models.ProjectLifecycleUnapproved && project.Lifecycle != models.ProjectLifecycleApprovalRequired {
|
|
||||||
return c.Redirect(hmnurl.BuildProjectHomepage(project.Slug), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
project = c.CurrentProject
|
|
||||||
}
|
|
||||||
|
|
||||||
if project == nil {
|
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
owners, err := FetchProjectOwners(c, project.ID)
|
// There are no further permission checks to do, because permissions are
|
||||||
|
// checked whatever way we fetch the project.
|
||||||
|
|
||||||
|
owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, err)
|
return c.ErrorResponse(http.StatusInternalServerError, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
canView := false
|
|
||||||
canEdit := false
|
|
||||||
if c.CurrentUser != nil {
|
|
||||||
if c.CurrentUser.IsStaff {
|
|
||||||
canView = true
|
|
||||||
canEdit = true
|
|
||||||
} else {
|
|
||||||
for _, owner := range owners {
|
|
||||||
if owner.ID == c.CurrentUser.ID {
|
|
||||||
canView = true
|
|
||||||
canEdit = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !canView {
|
|
||||||
if project.Flags == 0 {
|
|
||||||
for _, lc := range models.VisibleProjectLifecycles {
|
|
||||||
if project.Lifecycle == lc {
|
|
||||||
canView = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !canView {
|
|
||||||
return FourOhFour(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetching screenshots")
|
c.Perf.StartBlock("SQL", "Fetching screenshots")
|
||||||
type screenshotQuery struct {
|
type screenshotQuery struct {
|
||||||
Filename string `db:"screenshot.file"`
|
Filename string `db:"screenshot.file"`
|
||||||
|
@ -302,7 +184,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
WHERE
|
WHERE
|
||||||
handmade_project_screenshots.project_id = $1
|
handmade_project_screenshots.project_id = $1
|
||||||
`,
|
`,
|
||||||
project.ID,
|
c.CurrentProject.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch screenshots for project"))
|
||||||
|
@ -322,7 +204,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
link.project_id = $1
|
link.project_id = $1
|
||||||
ORDER BY link.ordering ASC
|
ORDER BY link.ordering ASC
|
||||||
`,
|
`,
|
||||||
project.ID,
|
c.CurrentProject.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project links"))
|
||||||
|
@ -352,7 +234,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
ORDER BY post.postdate DESC
|
ORDER BY post.postdate DESC
|
||||||
LIMIT $2
|
LIMIT $2
|
||||||
`,
|
`,
|
||||||
project.ID,
|
c.CurrentProject.ID,
|
||||||
maxRecentActivity,
|
maxRecentActivity,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -360,62 +242,62 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
var projectHomepageData ProjectHomepageData
|
var templateData ProjectHomepageData
|
||||||
|
|
||||||
projectHomepageData.BaseData = getBaseData(c, project.Name, nil)
|
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
|
||||||
if canEdit {
|
//if canEdit {
|
||||||
// TODO: Move to project-specific navigation
|
// // TODO: Move to project-specific navigation
|
||||||
// projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
// // templateData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
||||||
}
|
//}
|
||||||
projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
||||||
Property: "og:description",
|
Property: "og:description",
|
||||||
Value: project.Blurb,
|
Value: c.CurrentProject.Blurb,
|
||||||
})
|
})
|
||||||
|
|
||||||
projectHomepageData.Project = templates.ProjectToTemplate(project, c.Theme)
|
templateData.Project = templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme)
|
||||||
for _, owner := range owners {
|
for _, owner := range owners {
|
||||||
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
|
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.Flags == 1 {
|
if c.CurrentProject.Hidden {
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"hidden",
|
"hidden",
|
||||||
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if project.Lifecycle != models.ProjectLifecycleActive {
|
if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive {
|
||||||
switch project.Lifecycle {
|
switch c.CurrentProject.Lifecycle {
|
||||||
case models.ProjectLifecycleUnapproved:
|
case models.ProjectLifecycleUnapproved:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"unapproved",
|
"unapproved",
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
"NOTICE: This project has not yet been submitted for approval. It is only visible to owners. Please <a href=\"%s\">submit it for approval</a> when the project content is ready for review.",
|
||||||
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
c.UrlContext.BuildProjectEdit("submit"),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleApprovalRequired:
|
case models.ProjectLifecycleApprovalRequired:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"unapproved",
|
"unapproved",
|
||||||
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
|
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleHiatus:
|
case models.ProjectLifecycleHiatus:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"hiatus",
|
"hiatus",
|
||||||
"NOTICE: This project is on hiatus and may not update for a while.",
|
"NOTICE: This project is on hiatus and may not update for a while.",
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleDead:
|
case models.ProjectLifecycleDead:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"dead",
|
"dead",
|
||||||
"NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.",
|
"NOTICE: Site staff have marked this project as being dead. If you intend to revive it, please contact a member of the Handmade Network staff.",
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleLTSRequired:
|
case models.ProjectLifecycleLTSRequired:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"lts-reqd",
|
"lts-reqd",
|
||||||
"NOTICE: This project is awaiting approval for maintenance-mode status.",
|
"NOTICE: This project is awaiting approval for maintenance-mode status.",
|
||||||
)
|
)
|
||||||
case models.ProjectLifecycleLTS:
|
case models.ProjectLifecycleLTS:
|
||||||
projectHomepageData.BaseData.AddImmediateNotice(
|
templateData.BaseData.AddImmediateNotice(
|
||||||
"lts",
|
"lts",
|
||||||
"NOTICE: This project has reached a state of completion.",
|
"NOTICE: This project has reached a state of completion.",
|
||||||
)
|
)
|
||||||
|
@ -423,26 +305,56 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
for _, screenshot := range screenshotQueryResult.ToSlice() {
|
||||||
projectHomepageData.Screenshots = append(projectHomepageData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
|
templateData.Screenshots = append(templateData.Screenshots, hmnurl.BuildUserFile(screenshot.(*screenshotQuery).Filename))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, link := range projectLinkResult.ToSlice() {
|
for _, link := range projectLinkResult.ToSlice() {
|
||||||
projectHomepageData.ProjectLinks = append(projectHomepageData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
|
templateData.ProjectLinks = append(templateData.ProjectLinks, templates.LinkToTemplate(&link.(*projectLinkQuery).Link))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, post := range postQueryResult.ToSlice() {
|
for _, post := range postQueryResult.ToSlice() {
|
||||||
projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem(
|
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
|
||||||
|
c.UrlContext,
|
||||||
lineageBuilder,
|
lineageBuilder,
|
||||||
&post.(*postQuery).Post,
|
&post.(*postQuery).Post,
|
||||||
&post.(*postQuery).Thread,
|
&post.(*postQuery).Thread,
|
||||||
project,
|
|
||||||
&post.(*postQuery).Author,
|
&post.(*postQuery).Author,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tagId := -1
|
||||||
|
if c.CurrentProject.TagID != nil {
|
||||||
|
tagId = *c.CurrentProject.TagID
|
||||||
|
}
|
||||||
|
|
||||||
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
|
Tags: []int{tagId},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project snippets"))
|
||||||
|
}
|
||||||
|
for _, s := range snippets {
|
||||||
|
item := SnippetToTimelineItem(
|
||||||
|
&s.Snippet,
|
||||||
|
s.Asset,
|
||||||
|
s.DiscordMessage,
|
||||||
|
s.Tags,
|
||||||
|
s.Owner,
|
||||||
|
c.Theme,
|
||||||
|
)
|
||||||
|
item.SmallInfo = true
|
||||||
|
templateData.RecentActivity = append(templateData.RecentActivity, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Perf.StartBlock("PROFILE", "Sort timeline")
|
||||||
|
sort.Slice(templateData.RecentActivity, func(i, j int) bool {
|
||||||
|
return templateData.RecentActivity[j].Date.Before(templateData.RecentActivity[i].Date)
|
||||||
|
})
|
||||||
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
var res ResponseData
|
var res ResponseData
|
||||||
err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf)
|
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,12 +30,21 @@ type Router struct {
|
||||||
|
|
||||||
type Route struct {
|
type Route struct {
|
||||||
Method string
|
Method string
|
||||||
Regex *regexp.Regexp
|
Regexes []*regexp.Regexp
|
||||||
Handler Handler
|
Handler Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Route) String() string {
|
||||||
|
var routeStrings []string
|
||||||
|
for _, regex := range r.Regexes {
|
||||||
|
routeStrings = append(routeStrings, regex.String())
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s %v", r.Method, routeStrings)
|
||||||
|
}
|
||||||
|
|
||||||
type RouteBuilder struct {
|
type RouteBuilder struct {
|
||||||
Router *Router
|
Router *Router
|
||||||
|
Prefixes []*regexp.Regexp
|
||||||
Middleware Middleware
|
Middleware Middleware
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -44,11 +53,17 @@ type Handler func(c *RequestContext) ResponseData
|
||||||
type Middleware func(h Handler) Handler
|
type Middleware func(h Handler) Handler
|
||||||
|
|
||||||
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
|
func (rb *RouteBuilder) Handle(methods []string, regex *regexp.Regexp, h Handler) {
|
||||||
|
// Ensure that this regex matches the start of the string
|
||||||
|
regexStr := regex.String()
|
||||||
|
if len(regexStr) == 0 || regexStr[0] != '^' {
|
||||||
|
panic("All routing regexes must begin with '^'")
|
||||||
|
}
|
||||||
|
|
||||||
h = rb.Middleware(h)
|
h = rb.Middleware(h)
|
||||||
for _, method := range methods {
|
for _, method := range methods {
|
||||||
rb.Router.Routes = append(rb.Router.Routes, Route{
|
rb.Router.Routes = append(rb.Router.Routes, Route{
|
||||||
Method: method,
|
Method: method,
|
||||||
Regex: regex,
|
Regexes: append(rb.Prefixes, regex),
|
||||||
Handler: h,
|
Handler: h,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -66,49 +81,72 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
|
||||||
rb.Handle([]string{http.MethodPost}, regex, h)
|
rb.Handle([]string{http.MethodPost}, regex, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (rb *RouteBuilder) Group(regex *regexp.Regexp, addRoutes func(rb *RouteBuilder)) {
|
||||||
|
newRb := *rb
|
||||||
|
newRb.Prefixes = append(newRb.Prefixes, regex)
|
||||||
|
addRoutes(&newRb)
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
func (r *Router) ServeHTTP(rw http.ResponseWriter, req *http.Request) {
|
||||||
path := req.URL.Path
|
nextroute:
|
||||||
for _, route := range r.Routes {
|
for _, route := range r.Routes {
|
||||||
if route.Method != "" && req.Method != route.Method {
|
if route.Method != "" && req.Method != route.Method {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
path = strings.TrimSuffix(path, "/")
|
currentPath := strings.TrimSuffix(req.URL.Path, "/")
|
||||||
if path == "" {
|
if currentPath == "" {
|
||||||
path = "/"
|
currentPath = "/"
|
||||||
}
|
}
|
||||||
|
|
||||||
match := route.Regex.FindStringSubmatch(path)
|
var params map[string]string
|
||||||
if match == nil {
|
for _, regex := range route.Regexes {
|
||||||
continue
|
match := regex.FindStringSubmatch(currentPath)
|
||||||
}
|
if len(match) == 0 {
|
||||||
|
continue nextroute
|
||||||
|
}
|
||||||
|
|
||||||
c := &RequestContext{
|
if params == nil {
|
||||||
Route: route.Regex.String(),
|
params = map[string]string{}
|
||||||
Logger: logging.GlobalLogger(),
|
}
|
||||||
Req: req,
|
subexpNames := regex.SubexpNames()
|
||||||
Res: rw,
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(match) > 0 {
|
|
||||||
params := map[string]string{}
|
|
||||||
subexpNames := route.Regex.SubexpNames()
|
|
||||||
for i, paramValue := range match {
|
for i, paramValue := range match {
|
||||||
paramName := subexpNames[i]
|
paramName := subexpNames[i]
|
||||||
if paramName == "" {
|
if paramName == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if _, alreadyExists := params[paramName]; alreadyExists {
|
||||||
|
logging.Warn().
|
||||||
|
Str("route", route.String()).
|
||||||
|
Str("paramName", paramName).
|
||||||
|
Msg("duplicate names for path parameters; last one wins")
|
||||||
|
}
|
||||||
params[paramName] = paramValue
|
params[paramName] = paramValue
|
||||||
}
|
}
|
||||||
c.PathParams = params
|
|
||||||
|
// Make sure that we never consume trailing slashes even if the route regex matches them
|
||||||
|
toConsume := strings.TrimSuffix(match[0], "/")
|
||||||
|
currentPath = currentPath[len(toConsume):]
|
||||||
|
if currentPath == "" {
|
||||||
|
currentPath = "/"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c := &RequestContext{
|
||||||
|
Route: route.String(),
|
||||||
|
Logger: logging.GlobalLogger(),
|
||||||
|
Req: req,
|
||||||
|
Res: rw,
|
||||||
|
PathParams: params,
|
||||||
|
}
|
||||||
|
c.PathParams = params
|
||||||
|
|
||||||
doRequest(rw, c, route.Handler)
|
doRequest(rw, c, route.Handler)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", path))
|
panic(fmt.Sprintf("Path '%s' did not match any routes! Make sure to register a wildcard route to act as a 404.", req.URL))
|
||||||
}
|
}
|
||||||
|
|
||||||
type RequestContext struct {
|
type RequestContext struct {
|
||||||
|
@ -126,6 +164,7 @@ type RequestContext struct {
|
||||||
CurrentUser *models.User
|
CurrentUser *models.User
|
||||||
CurrentSession *models.Session
|
CurrentSession *models.Session
|
||||||
Theme string
|
Theme string
|
||||||
|
UrlContext *hmnurl.UrlContext
|
||||||
|
|
||||||
Perf *perf.RequestPerf
|
Perf *perf.RequestPerf
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,8 @@ import (
|
||||||
"math/rand"
|
"math/rand"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
@ -154,14 +156,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
return res
|
return res
|
||||||
})
|
})
|
||||||
|
|
||||||
anyProject.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
|
||||||
if c.CurrentProject.IsHMN() {
|
|
||||||
return Index(c)
|
|
||||||
} else {
|
|
||||||
return ProjectHomepage(c)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// NOTE(asaf): HMN-only routes:
|
// NOTE(asaf): HMN-only routes:
|
||||||
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
||||||
hmnOnly.GET(hmnurl.RegexAbout, About)
|
hmnOnly.GET(hmnurl.RegexAbout, About)
|
||||||
|
@ -175,14 +169,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexOldHome, Index)
|
hmnOnly.GET(hmnurl.RegexOldHome, Index)
|
||||||
|
|
||||||
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login)) // TODO(asaf): Adjust this after launch
|
hmnOnly.POST(hmnurl.RegexLoginAction, securityTimerMiddleware(time.Millisecond*100, Login))
|
||||||
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
|
hmnOnly.GET(hmnurl.RegexLogoutAction, Logout)
|
||||||
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
|
hmnOnly.GET(hmnurl.RegexLoginPage, LoginPage)
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
|
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
|
||||||
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
|
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
|
||||||
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
|
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
|
||||||
hmnOnly.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch
|
|
||||||
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
||||||
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
hmnOnly.POST(hmnurl.RegexEmailConfirmation, EmailConfirmationSubmit)
|
||||||
|
|
||||||
|
@ -202,7 +195,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
|
hmnOnly.GET(hmnurl.RegexShowcase, Showcase)
|
||||||
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
hmnOnly.GET(hmnurl.RegexSnippet, Snippet)
|
||||||
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
||||||
hmnOnly.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||||
|
@ -224,37 +216,121 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
|
|
||||||
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
||||||
|
|
||||||
// NOTE(asaf): Any-project routes:
|
attachProjectRoutes := func(rb *RouteBuilder) {
|
||||||
anyProject.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
||||||
anyProject.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
if c.CurrentProject.IsHMN() {
|
||||||
anyProject.GET(hmnurl.RegexForumThread, ForumThread)
|
return Index(c)
|
||||||
anyProject.GET(hmnurl.RegexForum, Forum)
|
} else {
|
||||||
anyProject.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
|
return ProjectHomepage(c)
|
||||||
anyProject.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
}
|
||||||
anyProject.GET(hmnurl.RegexForumPostReply, authMiddleware(ForumPostReply))
|
})
|
||||||
anyProject.POST(hmnurl.RegexForumPostReply, authMiddleware(csrfMiddleware(ForumPostReplySubmit)))
|
|
||||||
anyProject.GET(hmnurl.RegexForumPostEdit, authMiddleware(ForumPostEdit))
|
|
||||||
anyProject.POST(hmnurl.RegexForumPostEdit, authMiddleware(csrfMiddleware(ForumPostEditSubmit)))
|
|
||||||
anyProject.GET(hmnurl.RegexForumPostDelete, authMiddleware(ForumPostDelete))
|
|
||||||
anyProject.POST(hmnurl.RegexForumPostDelete, authMiddleware(csrfMiddleware(ForumPostDeleteSubmit)))
|
|
||||||
anyProject.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
|
|
||||||
|
|
||||||
anyProject.GET(hmnurl.RegexBlog, BlogIndex)
|
// Middleware used for forum action routes - anything related to actually creating or editing forum content
|
||||||
anyProject.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
needsForums := func(h Handler) Handler {
|
||||||
anyProject.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
return func(c *RequestContext) ResponseData {
|
||||||
anyProject.GET(hmnurl.RegexBlogThread, BlogThread)
|
// 404 if the project has forums disabled
|
||||||
anyProject.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
if !c.CurrentProject.HasForums() {
|
||||||
anyProject.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
|
return FourOhFour(c)
|
||||||
anyProject.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
|
}
|
||||||
anyProject.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
|
// Require auth if forums are enabled
|
||||||
anyProject.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
|
return authMiddleware(h)(c)
|
||||||
anyProject.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
|
}
|
||||||
anyProject.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
|
}
|
||||||
anyProject.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
|
rb.POST(hmnurl.RegexForumNewThreadSubmit, needsForums(csrfMiddleware(ForumNewThreadSubmit)))
|
||||||
return c.Redirect(hmnurl.ProjectUrl(
|
rb.GET(hmnurl.RegexForumNewThread, needsForums(ForumNewThread))
|
||||||
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
|
rb.GET(hmnurl.RegexForumThread, ForumThread)
|
||||||
c.CurrentProject.Slug,
|
rb.GET(hmnurl.RegexForum, Forum)
|
||||||
), http.StatusMovedPermanently)
|
rb.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead))) // needs auth but doesn't need forums enabled
|
||||||
|
rb.GET(hmnurl.RegexForumPost, ForumPostRedirect)
|
||||||
|
rb.GET(hmnurl.RegexForumPostReply, needsForums(ForumPostReply))
|
||||||
|
rb.POST(hmnurl.RegexForumPostReply, needsForums(csrfMiddleware(ForumPostReplySubmit)))
|
||||||
|
rb.GET(hmnurl.RegexForumPostEdit, needsForums(ForumPostEdit))
|
||||||
|
rb.POST(hmnurl.RegexForumPostEdit, needsForums(csrfMiddleware(ForumPostEditSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexForumPostDelete, needsForums(ForumPostDelete))
|
||||||
|
rb.POST(hmnurl.RegexForumPostDelete, needsForums(csrfMiddleware(ForumPostDeleteSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexWikiArticle, WikiArticleRedirect)
|
||||||
|
|
||||||
|
// Middleware used for blog action routes - anything related to actually creating or editing blog content
|
||||||
|
needsBlogs := func(h Handler) Handler {
|
||||||
|
return func(c *RequestContext) ResponseData {
|
||||||
|
// 404 if the project has blogs disabled
|
||||||
|
if !c.CurrentProject.HasBlog() {
|
||||||
|
return FourOhFour(c)
|
||||||
|
}
|
||||||
|
// Require auth if blogs are enabled
|
||||||
|
return authMiddleware(h)(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rb.GET(hmnurl.RegexBlog, BlogIndex)
|
||||||
|
rb.GET(hmnurl.RegexBlogNewThread, needsBlogs(BlogNewThread))
|
||||||
|
rb.POST(hmnurl.RegexBlogNewThread, needsBlogs(csrfMiddleware(BlogNewThreadSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogThread, BlogThread)
|
||||||
|
rb.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
||||||
|
rb.GET(hmnurl.RegexBlogPostReply, needsBlogs(BlogPostReply))
|
||||||
|
rb.POST(hmnurl.RegexBlogPostReply, needsBlogs(csrfMiddleware(BlogPostReplySubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogPostEdit, needsBlogs(BlogPostEdit))
|
||||||
|
rb.POST(hmnurl.RegexBlogPostEdit, needsBlogs(csrfMiddleware(BlogPostEditSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogPostDelete, needsBlogs(BlogPostDelete))
|
||||||
|
rb.POST(hmnurl.RegexBlogPostDelete, needsBlogs(csrfMiddleware(BlogPostDeleteSubmit)))
|
||||||
|
rb.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
|
||||||
|
return c.Redirect(c.UrlContext.Url(
|
||||||
|
fmt.Sprintf("blog%s", c.PathParams["remainder"]), nil,
|
||||||
|
), http.StatusMovedPermanently)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
hmnOnly.Group(hmnurl.RegexPersonalProject, func(rb *RouteBuilder) {
|
||||||
|
// TODO(ben): Perhaps someday we can make this middleware modification feel better? It seems
|
||||||
|
// pretty common to run the outermost middleware first before doing other stuff, but having
|
||||||
|
// to nest functions this way feels real bad.
|
||||||
|
rb.Middleware = func(h Handler) Handler {
|
||||||
|
return hmnOnly.Middleware(func(c *RequestContext) ResponseData {
|
||||||
|
// At this point we are definitely on the plain old HMN subdomain.
|
||||||
|
|
||||||
|
// Fetch personal project and do whatever
|
||||||
|
id, err := strconv.Atoi(c.PathParams["projectid"])
|
||||||
|
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{})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, db.NotFound) {
|
||||||
|
return FourOhFour(c)
|
||||||
|
} else {
|
||||||
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch personal project"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.CurrentProject = &p.Project
|
||||||
|
c.UrlContext = UrlContextForProject(c.CurrentProject)
|
||||||
|
|
||||||
|
if !p.Project.Personal {
|
||||||
|
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.PathParams["projectslug"] != models.GeneratePersonalProjectSlug(p.Project.Name) {
|
||||||
|
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
attachProjectRoutes(rb)
|
||||||
|
})
|
||||||
|
anyProject.Group(regexp.MustCompile("^"), func(rb *RouteBuilder) {
|
||||||
|
rb.Middleware = func(h Handler) Handler {
|
||||||
|
return anyProject.Middleware(func(c *RequestContext) ResponseData {
|
||||||
|
// We could be on any project's subdomain.
|
||||||
|
|
||||||
|
// Check if the current project (matched by subdomain) is actually no longer official
|
||||||
|
// and therefore needs to be redirected to the personal project version of the route.
|
||||||
|
if c.CurrentProject.Personal {
|
||||||
|
return c.Redirect(c.UrlContext.RewriteProjectUrl(c.URL()), http.StatusSeeOther)
|
||||||
|
}
|
||||||
|
|
||||||
|
return h(c)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
attachProjectRoutes(rb)
|
||||||
})
|
})
|
||||||
|
|
||||||
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
|
anyProject.POST(hmnurl.RegexAssetUpload, AssetUpload)
|
||||||
|
@ -277,31 +353,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
||||||
return router
|
return router
|
||||||
}
|
}
|
||||||
|
|
||||||
func FetchProjectBySlug(ctx context.Context, conn *pgxpool.Pool, slug string) (*models.Project, error) {
|
|
||||||
if len(slug) > 0 && slug != models.HMNProjectSlug {
|
|
||||||
subdomainProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE slug = $1", slug)
|
|
||||||
if err == nil {
|
|
||||||
subdomainProject := subdomainProjectRow.(*models.Project)
|
|
||||||
return subdomainProject, nil
|
|
||||||
} else if !errors.Is(err, db.NotFound) {
|
|
||||||
return nil, oops.New(err, "failed to get projects by slug")
|
|
||||||
} else {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
defaultProjectRow, err := db.QueryOne(ctx, conn, models.Project{}, "SELECT $columns FROM handmade_project WHERE id = $1", models.HMNProjectID)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, db.NotFound) {
|
|
||||||
return nil, oops.New(nil, "default project didn't exist in the database")
|
|
||||||
} else {
|
|
||||||
return nil, oops.New(err, "failed to get default project")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
defaultProject := defaultProjectRow.(*models.Project)
|
|
||||||
return defaultProject, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ProjectCSS(c *RequestContext) ResponseData {
|
func ProjectCSS(c *RequestContext) ResponseData {
|
||||||
color := c.URL().Query().Get("color")
|
color := c.URL().Query().Get("color")
|
||||||
if color == "" {
|
if color == "" {
|
||||||
|
@ -382,22 +433,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
||||||
defer c.Perf.EndBlock()
|
defer c.Perf.EndBlock()
|
||||||
|
|
||||||
// get project
|
// get user
|
||||||
{
|
|
||||||
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
|
||||||
slug := strings.TrimRight(hostPrefix, ".")
|
|
||||||
|
|
||||||
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, slug)
|
|
||||||
if err != nil {
|
|
||||||
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
|
||||||
}
|
|
||||||
if dbProject == nil {
|
|
||||||
return false, c.Redirect(hmnurl.BuildHomepage(), http.StatusSeeOther)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.CurrentProject = dbProject
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
{
|
||||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
@ -412,12 +448,43 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
||||||
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
|
// http.ErrNoCookie is the only error Cookie ever returns, so no further handling to do here.
|
||||||
}
|
}
|
||||||
|
|
||||||
theme := "light"
|
// get official project
|
||||||
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
{
|
||||||
theme = "dark"
|
hostPrefix := strings.TrimSuffix(c.Req.Host, hmnurl.GetBaseHost())
|
||||||
|
slug := strings.TrimRight(hostPrefix, ".")
|
||||||
|
|
||||||
|
dbProject, err := FetchProjectBySlug(c.Context(), c.Conn, c.CurrentUser, slug, ProjectsQuery{})
|
||||||
|
if err == nil {
|
||||||
|
c.CurrentProject = &dbProject.Project
|
||||||
|
} else {
|
||||||
|
if errors.Is(err, db.NotFound) {
|
||||||
|
// do nothing, this is fine
|
||||||
|
} else {
|
||||||
|
return false, c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch current project"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CurrentProject == nil {
|
||||||
|
dbProject, err := FetchProject(c.Context(), c.Conn, c.CurrentUser, models.HMNProjectID, ProjectsQuery{
|
||||||
|
IncludeHidden: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(oops.New(err, "failed to fetch HMN project"))
|
||||||
|
}
|
||||||
|
c.CurrentProject = &dbProject.Project
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.CurrentProject == nil {
|
||||||
|
panic("failed to load project data")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.UrlContext = UrlContextForProject(c.CurrentProject)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Theme = theme
|
c.Theme = "light"
|
||||||
|
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
||||||
|
c.Theme = "dark"
|
||||||
|
}
|
||||||
|
|
||||||
return true, ResponseData{}
|
return true, ResponseData{}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,7 @@ package website
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
@ -17,34 +15,14 @@ type ShowcaseData struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Showcase(c *RequestContext) ResponseData {
|
func Showcase(c *RequestContext) ResponseData {
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{})
|
||||||
type snippetQuery struct {
|
|
||||||
Owner models.User `db:"owner"`
|
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
NOT snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
for _, s := range snippetQuerySlice {
|
for _, s := range snippets {
|
||||||
row := s.(*snippetQuery)
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
if timelineItem.CanShowcase {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"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/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
@ -30,25 +29,7 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch snippet")
|
s, err := FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, SnippetQuery{})
|
||||||
type snippetQuery struct {
|
|
||||||
Owner models.User `db:"owner"`
|
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.QueryOne(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE snippet.id = $1
|
|
||||||
`,
|
|
||||||
snippetId,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.NotFound) {
|
if errors.Is(err, db.NotFound) {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
|
@ -58,9 +39,7 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
snippetData := snippetQueryResult.(*snippetQuery)
|
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
|
|
||||||
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
|
|
||||||
|
|
||||||
opengraph := []templates.OpenGraphItem{
|
opengraph := []templates.OpenGraphItem{
|
||||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||||
|
|
|
@ -0,0 +1,211 @@
|
||||||
|
package website
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SnippetQuery struct {
|
||||||
|
IDs []int
|
||||||
|
OwnerIDs []int
|
||||||
|
Tags []int
|
||||||
|
|
||||||
|
Limit, Offset int // if empty, no pagination
|
||||||
|
}
|
||||||
|
|
||||||
|
type SnippetAndStuff struct {
|
||||||
|
Snippet models.Snippet
|
||||||
|
Owner *models.User
|
||||||
|
Asset *models.Asset `db:"asset"`
|
||||||
|
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||||
|
Tags []*models.Tag
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchSnippets(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
currentUser *models.User,
|
||||||
|
q SnippetQuery,
|
||||||
|
) ([]SnippetAndStuff, error) {
|
||||||
|
perf := ExtractPerf(ctx)
|
||||||
|
perf.StartBlock("SQL", "Fetch snippets")
|
||||||
|
defer perf.EndBlock()
|
||||||
|
|
||||||
|
tx, err := dbConn.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to start transaction")
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var qb db.QueryBuilder
|
||||||
|
qb.Add(
|
||||||
|
`
|
||||||
|
SELECT $columns
|
||||||
|
FROM
|
||||||
|
handmade_snippet AS snippet
|
||||||
|
LEFT JOIN auth_user AS owner ON snippet.owner_id = owner.id
|
||||||
|
LEFT JOIN handmade_asset AS asset ON snippet.asset_id = asset.id
|
||||||
|
LEFT JOIN handmade_discordmessage AS discord_message ON snippet.discord_message_id = discord_message.id
|
||||||
|
LEFT JOIN snippet_tags ON snippet.id = snippet_tags.snippet_id
|
||||||
|
LEFT JOIN tags ON snippet_tags.tag_id = tags.id
|
||||||
|
WHERE
|
||||||
|
TRUE
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
if len(q.IDs) > 0 {
|
||||||
|
qb.Add(`AND snippet.id = ANY ($?)`, q.IDs)
|
||||||
|
}
|
||||||
|
if len(q.OwnerIDs) > 0 {
|
||||||
|
qb.Add(`AND snippet.owner_id = ANY ($?)`, q.OwnerIDs)
|
||||||
|
}
|
||||||
|
if len(q.Tags) > 0 {
|
||||||
|
qb.Add(`AND snippet_tags.tag_id = ANY ($?)`, q.Tags)
|
||||||
|
}
|
||||||
|
if currentUser == nil {
|
||||||
|
qb.Add(
|
||||||
|
`AND owner.status = $? -- snippet owner is Approved`,
|
||||||
|
models.UserStatusApproved,
|
||||||
|
)
|
||||||
|
} else if !currentUser.IsStaff {
|
||||||
|
qb.Add(
|
||||||
|
`
|
||||||
|
AND (
|
||||||
|
owner.status = $? -- snippet owner is Approved
|
||||||
|
OR owner.id = $? -- current user is the snippet owner
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
models.UserStatusApproved,
|
||||||
|
currentUser.ID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
qb.Add(`ORDER BY snippet.when DESC, snippet.id ASC`)
|
||||||
|
if q.Limit > 0 {
|
||||||
|
qb.Add(`LIMIT $? OFFSET $?`, q.Limit, q.Offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
type resultRow struct {
|
||||||
|
Snippet models.Snippet `db:"snippet"`
|
||||||
|
Owner *models.User `db:"owner"`
|
||||||
|
Asset *models.Asset `db:"asset"`
|
||||||
|
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||||
|
Tag *models.Tag `db:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
it, err := db.Query(ctx, dbConn, resultRow{}, qb.String(), qb.Args()...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to fetch threads")
|
||||||
|
}
|
||||||
|
iresults := it.ToSlice()
|
||||||
|
|
||||||
|
result := make([]SnippetAndStuff, 0, len(iresults)) // allocate extra space because why not
|
||||||
|
currentSnippetId := -1
|
||||||
|
for _, iresult := range iresults {
|
||||||
|
row := *iresult.(*resultRow)
|
||||||
|
|
||||||
|
if row.Snippet.ID != currentSnippetId {
|
||||||
|
// we have moved onto a new snippet; make a new entry
|
||||||
|
result = append(result, SnippetAndStuff{
|
||||||
|
Snippet: row.Snippet,
|
||||||
|
Owner: row.Owner,
|
||||||
|
Asset: row.Asset,
|
||||||
|
DiscordMessage: row.DiscordMessage,
|
||||||
|
// no tags! tags next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if row.Tag != nil {
|
||||||
|
result[len(result)-1].Tags = append(result[len(result)-1].Tags, row.Tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, oops.New(err, "failed to commit transaction")
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func FetchSnippet(
|
||||||
|
ctx context.Context,
|
||||||
|
dbConn db.ConnOrTx,
|
||||||
|
currentUser *models.User,
|
||||||
|
snippetID int,
|
||||||
|
q SnippetQuery,
|
||||||
|
) (SnippetAndStuff, error) {
|
||||||
|
q.IDs = []int{snippetID}
|
||||||
|
q.Limit = 1
|
||||||
|
q.Offset = 0
|
||||||
|
|
||||||
|
res, err := FetchSnippets(ctx, dbConn, currentUser, q)
|
||||||
|
if err != nil {
|
||||||
|
return SnippetAndStuff{}, oops.New(err, "failed to fetch snippet")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(res) == 0 {
|
||||||
|
return SnippetAndStuff{}, db.NotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
|
@ -88,7 +88,7 @@ func FetchThreads(
|
||||||
WHERE
|
WHERE
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND ( -- project has valid lifecycle
|
AND ( -- project has valid lifecycle
|
||||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||||
OR project.id = $?
|
OR project.id = $?
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
@ -219,7 +219,7 @@ func CountThreads(
|
||||||
WHERE
|
WHERE
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND ( -- project has valid lifecycle
|
AND ( -- project has valid lifecycle
|
||||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||||
OR project.id = $?
|
OR project.id = $?
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
@ -343,7 +343,7 @@ func FetchPosts(
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND NOT post.deleted
|
AND NOT post.deleted
|
||||||
AND ( -- project has valid lifecycle
|
AND ( -- project has valid lifecycle
|
||||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||||
OR project.id = $?
|
OR project.id = $?
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
@ -543,7 +543,7 @@ func CountPosts(
|
||||||
NOT thread.deleted
|
NOT thread.deleted
|
||||||
AND NOT post.deleted
|
AND NOT post.deleted
|
||||||
AND ( -- project has valid lifecycle
|
AND ( -- project has valid lifecycle
|
||||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||||
OR project.id = $?
|
OR project.id = $?
|
||||||
)
|
)
|
||||||
`,
|
`,
|
||||||
|
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
|
@ -24,18 +25,18 @@ var TimelineTypeTitleMap = map[models.ThreadType]TimelineTypeTitles{
|
||||||
}
|
}
|
||||||
|
|
||||||
func PostToTimelineItem(
|
func PostToTimelineItem(
|
||||||
|
urlContext *hmnurl.UrlContext,
|
||||||
lineageBuilder *models.SubforumLineageBuilder,
|
lineageBuilder *models.SubforumLineageBuilder,
|
||||||
post *models.Post,
|
post *models.Post,
|
||||||
thread *models.Thread,
|
thread *models.Thread,
|
||||||
project *models.Project,
|
|
||||||
owner *models.User,
|
owner *models.User,
|
||||||
currentTheme string,
|
currentTheme string,
|
||||||
) templates.TimelineItem {
|
) templates.TimelineItem {
|
||||||
item := templates.TimelineItem{
|
item := templates.TimelineItem{
|
||||||
Date: post.PostDate,
|
Date: post.PostDate,
|
||||||
Title: thread.Title,
|
Title: thread.Title,
|
||||||
Breadcrumbs: GenericThreadBreadcrumbs(lineageBuilder, project, thread),
|
Breadcrumbs: GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread),
|
||||||
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
|
Url: UrlForGenericPost(urlContext, thread, post, lineageBuilder),
|
||||||
|
|
||||||
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
||||||
OwnerName: owner.BestName(),
|
OwnerName: owner.BestName(),
|
||||||
|
@ -63,6 +64,7 @@ func SnippetToTimelineItem(
|
||||||
snippet *models.Snippet,
|
snippet *models.Snippet,
|
||||||
asset *models.Asset,
|
asset *models.Asset,
|
||||||
discordMessage *models.DiscordMessage,
|
discordMessage *models.DiscordMessage,
|
||||||
|
tags []*models.Tag,
|
||||||
owner *models.User,
|
owner *models.User,
|
||||||
currentTheme string,
|
currentTheme string,
|
||||||
) templates.TimelineItem {
|
) templates.TimelineItem {
|
||||||
|
@ -106,6 +108,13 @@ func SnippetToTimelineItem(
|
||||||
item.DiscordMessageUrl = discordMessage.Url
|
item.DiscordMessageUrl = discordMessage.Url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sort.Slice(tags, func(i, j int) bool {
|
||||||
|
return tags[i].Text < tags[j].Text
|
||||||
|
})
|
||||||
|
for _, tag := range tags {
|
||||||
|
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
|
||||||
|
}
|
||||||
|
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
|
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
|
||||||
WHERE
|
WHERE
|
||||||
uproj.user_id = $1
|
uproj.user_id = $1
|
||||||
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
|
AND ($2 OR (NOT project.hidden AND project.lifecycle = ANY ($3)))
|
||||||
`,
|
`,
|
||||||
profileUser.ID,
|
profileUser.ID,
|
||||||
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
|
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsStaff)),
|
||||||
|
@ -121,7 +121,11 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
|
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
|
||||||
for _, projectRow := range projectQuerySlice {
|
for _, projectRow := range projectQuerySlice {
|
||||||
projectData := projectRow.(*projectQuery)
|
projectData := projectRow.(*projectQuery)
|
||||||
templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme))
|
templateProjects = append(templateProjects, templates.ProjectToTemplate(
|
||||||
|
&projectData.Project,
|
||||||
|
UrlContextForProject(&projectData.Project).BuildHomepage(),
|
||||||
|
c.Theme,
|
||||||
|
))
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
|
@ -132,29 +136,12 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
})
|
})
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
type snippetQuery struct {
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
Snippet models.Snippet `db:"snippet"`
|
OwnerIDs: []int{profileUser.ID},
|
||||||
Asset *models.Asset `db:"asset"`
|
})
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch snippets")
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
snippet.owner_id = $1
|
|
||||||
`,
|
|
||||||
profileUser.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||||
|
@ -162,25 +149,25 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
c.Perf.StartBlock("PROFILE", "Construct timeline items")
|
c.Perf.StartBlock("PROFILE", "Construct timeline items")
|
||||||
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippetQuerySlice))
|
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
|
||||||
|
|
||||||
for _, post := range posts {
|
for _, post := range posts {
|
||||||
timelineItems = append(timelineItems, PostToTimelineItem(
|
timelineItems = append(timelineItems, PostToTimelineItem(
|
||||||
|
UrlContextForProject(&post.Project),
|
||||||
lineageBuilder,
|
lineageBuilder,
|
||||||
&post.Post,
|
&post.Post,
|
||||||
&post.Thread,
|
&post.Thread,
|
||||||
&post.Project,
|
|
||||||
profileUser,
|
profileUser,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, snippetRow := range snippetQuerySlice {
|
for _, s := range snippets {
|
||||||
snippetData := snippetRow.(*snippetQuery)
|
|
||||||
item := SnippetToTimelineItem(
|
item := SnippetToTimelineItem(
|
||||||
&snippetData.Snippet,
|
&s.Snippet,
|
||||||
snippetData.Asset,
|
s.Asset,
|
||||||
snippetData.DiscordMessage,
|
s.DiscordMessage,
|
||||||
|
s.Tags,
|
||||||
profileUser,
|
profileUser,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue