Merge branch 'personal-projects'
This commit is contained in:
commit
908fa4368f
|
@ -19,4 +19,5 @@ pushd $BETA_PATH
|
|||
|
||||
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
|
||||
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
|
||||
|
|
|
@ -7,6 +7,7 @@ const TimelineMediaTypes = {
|
|||
|
||||
const showcaseItemTemplate = makeTemplateCloner("showcase_item");
|
||||
const modalTemplate = makeTemplateCloner("timeline_modal");
|
||||
const tagTemplate = makeTemplateCloner("timeline_item_tag");
|
||||
|
||||
function showcaseTimestamp(rawDate) {
|
||||
const date = new Date(rawDate*1000);
|
||||
|
@ -95,6 +96,17 @@ function makeShowcaseItem(timelineItem) {
|
|||
modalEl.date.textContent = timestamp;
|
||||
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;
|
||||
|
||||
function close() {
|
||||
|
|
|
@ -60,6 +60,12 @@ type ConnOrTx interface {
|
|||
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
|
||||
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
|
||||
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()
|
||||
|
|
|
@ -64,7 +64,12 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message, is
|
|||
}
|
||||
|
||||
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 {
|
||||
return oops.New(err, "failed to mark snippet as a jam snippet")
|
||||
}
|
||||
|
|
|
@ -6,8 +6,9 @@ import (
|
|||
"regexp"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
)
|
||||
|
||||
|
@ -62,34 +63,31 @@ func GetBaseHost() string {
|
|||
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 {
|
||||
return ProjectUrl(path, query, "")
|
||||
return UrlWithFragment(path, query, "")
|
||||
}
|
||||
|
||||
func ProjectUrl(path string, query []Q, slug string) string {
|
||||
return ProjectUrlWithFragment(path, query, slug, "")
|
||||
func UrlWithFragment(path string, query []Q, fragment string) string {
|
||||
return HMNProjectContext.UrlWithFragment(path, query, fragment)
|
||||
}
|
||||
|
||||
func ProjectUrlWithFragment(path string, query []Q, slug string, fragment string) string {
|
||||
subdomain := slug
|
||||
if slug == models.HMNProjectSlug {
|
||||
subdomain = ""
|
||||
}
|
||||
|
||||
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 (c *UrlContext) RewriteProjectUrl(u *url.URL) string {
|
||||
// we need to strip anything matching the personal project regex to get the base path
|
||||
match := RegexPersonalProject.FindString(u.Path)
|
||||
return c.Url(u.Path[len(match):], QFromURL(u))
|
||||
}
|
||||
|
||||
func trim(path string) string {
|
||||
|
|
|
@ -21,12 +21,11 @@ var RegexOldHome = regexp.MustCompile("^/home$")
|
|||
var RegexHomepage = regexp.MustCompile("^/$")
|
||||
|
||||
func BuildHomepage() string {
|
||||
return Url("/", nil)
|
||||
return HMNProjectContext.BuildHomepage()
|
||||
}
|
||||
|
||||
func BuildProjectHomepage(projectSlug string) string {
|
||||
defer CatchPanic()
|
||||
return ProjectUrl("/", nil, projectSlug)
|
||||
func (c *UrlContext) BuildHomepage() string {
|
||||
return c.Url("/", nil)
|
||||
}
|
||||
|
||||
var RegexShowcase = regexp.MustCompile("^/showcase$")
|
||||
|
@ -97,8 +96,6 @@ func BuildRegistrationSuccess() string {
|
|||
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>[^/]+)$")
|
||||
|
||||
func BuildEmailConfirmation(username, token string) string {
|
||||
|
@ -198,7 +195,7 @@ func BuildUserProfile(username string) string {
|
|||
var RegexUserSettings = regexp.MustCompile(`^/settings$`)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
return Url(fmt.Sprintf("/p/%s", slug), nil)
|
||||
return Url(fmt.Sprintf("/p/%d/%s", id, 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()
|
||||
|
||||
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.
|
||||
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()
|
||||
if page < 1 {
|
||||
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))
|
||||
}
|
||||
|
||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||
return c.Url(builder.String(), nil)
|
||||
}
|
||||
|
||||
var RegexForumNewThread = regexp.MustCompile(`^/forums(/(?P<subforums>[^\d/]+(/[^\d]+)*))?/t/new$`)
|
||||
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()
|
||||
builder := buildSubforumPath(subforums)
|
||||
builder.WriteString("/t/new")
|
||||
|
@ -397,59 +436,59 @@ func BuildForumNewThread(projectSlug string, subforums []string, submit bool) st
|
|||
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+))?$`)
|
||||
|
||||
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()
|
||||
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()
|
||||
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+)$`)
|
||||
|
||||
func BuildForumPost(projectSlug string, subforums []string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildForumPost(subforums []string, threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
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$`)
|
||||
|
||||
func BuildForumPostDelete(projectSlug string, subforums []string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildForumPostDelete(subforums []string, threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildForumPostPath(subforums, threadId, postId)
|
||||
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$`)
|
||||
|
||||
func BuildForumPostEdit(projectSlug string, subforums []string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildForumPostEdit(subforums []string, threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildForumPostPath(subforums, threadId, postId)
|
||||
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$`)
|
||||
|
||||
func BuildForumPostReply(projectSlug string, subforums []string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildForumPostReply(subforums []string, threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildForumPostPath(subforums, threadId, postId)
|
||||
builder.WriteString("/reply")
|
||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||
return c.Url(builder.String(), nil)
|
||||
}
|
||||
|
||||
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+))?$`)
|
||||
|
||||
func BuildBlog(projectSlug string, page int) string {
|
||||
func (c *UrlContext) BuildBlog(page int) string {
|
||||
defer CatchPanic()
|
||||
if page < 1 {
|
||||
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)
|
||||
}
|
||||
|
||||
return ProjectUrl(path, nil, projectSlug)
|
||||
return c.Url(path, nil)
|
||||
}
|
||||
|
||||
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()
|
||||
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()
|
||||
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$`)
|
||||
|
||||
func BuildBlogNewThread(projectSlug string) string {
|
||||
func (c *UrlContext) BuildBlogNewThread() string {
|
||||
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+)$`)
|
||||
|
||||
func BuildBlogPost(projectSlug string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildBlogPost(threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
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$`)
|
||||
|
||||
func BuildBlogPostDelete(projectSlug string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildBlogPostDelete(threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildBlogPostPath(threadId, postId)
|
||||
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$`)
|
||||
|
||||
func BuildBlogPostEdit(projectSlug string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildBlogPostEdit(threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildBlogPostPath(threadId, postId)
|
||||
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$`)
|
||||
|
||||
func BuildBlogPostReply(projectSlug string, threadId int, postId int) string {
|
||||
func (c *UrlContext) BuildBlogPostReply(threadId int, postId int) string {
|
||||
defer CatchPanic()
|
||||
builder := buildBlogPostPath(threadId, postId)
|
||||
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>[^/]+))?$`)
|
||||
|
||||
func BuildEpisodeList(projectSlug string, topic string) string {
|
||||
func (c *UrlContext) BuildEpisodeList(topic string) string {
|
||||
defer CatchPanic()
|
||||
|
||||
var builder strings.Builder
|
||||
|
@ -592,21 +631,21 @@ func BuildEpisodeList(projectSlug string, topic string) string {
|
|||
builder.WriteString("/")
|
||||
builder.WriteString(topic)
|
||||
}
|
||||
return ProjectUrl(builder.String(), nil, projectSlug)
|
||||
return c.Url(builder.String(), nil)
|
||||
}
|
||||
|
||||
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()
|
||||
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$`)
|
||||
|
||||
func BuildCineraIndex(projectSlug string, topic string) string {
|
||||
func (c *UrlContext) BuildCineraIndex(topic string) string {
|
||||
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$")
|
||||
|
||||
// NOTE(asaf): Providing the projectSlug avoids any CORS problems.
|
||||
func BuildAssetUpload(projectSlug string) string {
|
||||
return ProjectUrl("/upload_asset", nil, projectSlug)
|
||||
func (c *UrlContext) BuildAssetUpload() string {
|
||||
return c.Url("/upload_asset", nil)
|
||||
}
|
||||
|
||||
/*
|
||||
|
@ -718,7 +757,7 @@ func BuildUserFile(filepath string) string {
|
|||
var RegexForumMarkRead = regexp.MustCompile(`^/markread/(?P<sfid>\d+)$`)
|
||||
|
||||
// NOTE(asaf): subforumId == 0 means ALL SUBFORUMS
|
||||
func BuildForumMarkRead(projectSlug string, subforumId int) string {
|
||||
func (c *UrlContext) BuildForumMarkRead(subforumId int) string {
|
||||
defer CatchPanic()
|
||||
if subforumId < 0 {
|
||||
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(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
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||
"git.handmade.network/hmn/hmn/src/oops"
|
||||
"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"
|
||||
)
|
||||
|
||||
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) {
|
||||
All[m.Version()] = m
|
||||
|
|
|
@ -2,6 +2,8 @@ package models
|
|||
|
||||
import (
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
@ -15,7 +17,7 @@ var ProjectType = reflect.TypeOf(Project{})
|
|||
type ProjectLifecycle int
|
||||
|
||||
const (
|
||||
ProjectLifecycleUnapproved = iota
|
||||
ProjectLifecycleUnapproved ProjectLifecycle = iota
|
||||
ProjectLifecycleApprovalRequired
|
||||
ProjectLifecycleActive
|
||||
ProjectLifecycleHiatus
|
||||
|
@ -41,6 +43,7 @@ type Project struct {
|
|||
|
||||
Slug string `db:"slug"`
|
||||
Name string `db:"name"`
|
||||
TagID *int `db:"tag"`
|
||||
Blurb string `db:"blurb"`
|
||||
Description string `db:"description"`
|
||||
ParsedDescription string `db:"descparsed"`
|
||||
|
@ -53,7 +56,8 @@ type Project struct {
|
|||
LogoLight string `db:"logolight"`
|
||||
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"`
|
||||
DateApproved time.Time `db:"date_approved"`
|
||||
AllLastUpdated time.Time `db:"all_last_updated"`
|
||||
|
@ -63,7 +67,7 @@ type Project struct {
|
|||
|
||||
ForumEnabled bool `db:"forum_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 {
|
||||
|
@ -77,3 +81,30 @@ func (p *Project) Subdomain() string {
|
|||
|
||||
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",
|
||||
}
|
||||
|
||||
func ProjectUrl(p *models.Project) string {
|
||||
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 {
|
||||
func ProjectToTemplate(p *models.Project, url string, theme string) Project {
|
||||
logo := p.LogoLight
|
||||
if theme == "dark" {
|
||||
logo = p.LogoDark
|
||||
}
|
||||
url := ProjectUrl(p)
|
||||
return Project{
|
||||
Name: p.Name,
|
||||
Subdomain: p.Subdomain(),
|
||||
|
@ -91,9 +80,8 @@ func ProjectToTemplate(p *models.Project, theme string) Project {
|
|||
|
||||
IsHMN: p.IsHMN(),
|
||||
|
||||
HasBlog: p.BlogEnabled,
|
||||
HasForum: p.ForumEnabled,
|
||||
HasLibrary: false, // TODO: port the library lol
|
||||
HasBlog: p.HasBlog(),
|
||||
HasForum: p.HasForums(),
|
||||
|
||||
DateApproved: p.DateApproved,
|
||||
}
|
||||
|
@ -319,8 +307,24 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
|||
|
||||
builder.WriteString(`"discord_message_url":"`)
|
||||
builder.WriteString(item.DiscordMessageUrl)
|
||||
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 {
|
||||
if s == nil {
|
||||
return ""
|
||||
|
|
|
@ -20,7 +20,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ if $.User }}
|
||||
{{ if and $.User $.Project.HasBlog }}
|
||||
<div class="flex">
|
||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||
|
@ -81,7 +81,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{{ if $.User }}
|
||||
{{ if and $.User $.Project.HasBlog }}
|
||||
<div class="flex">
|
||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||
|
@ -111,11 +111,13 @@
|
|||
|
||||
<div class="optionbar bottom">
|
||||
<div class="options">
|
||||
{{ if .Project.HasBlog }}
|
||||
{{ if $.User }}
|
||||
<a class="button" href="{{ .ReplyLink }}"><span class="big pr1">+</span> Add Comment</a>
|
||||
{{ else }}
|
||||
<a class="button" href="{{ .LoginLink }}">Log in to comment</a>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -34,14 +34,18 @@
|
|||
|
||||
{{ define "subforum_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 }}
|
||||
<form method="POST" action="{{ .MarkReadUrl }}">
|
||||
{{ csrftoken .Session }}
|
||||
<button type="submit"><span class="big pr1">✓</span> Mark threads here as read</button>
|
||||
</form>
|
||||
{{ else }}
|
||||
<a class="button" href="{{ .LoginPageUrl }}">Log in to post a new thread</a>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="options">
|
||||
|
|
|
@ -61,7 +61,7 @@
|
|||
<div class="postid">
|
||||
<a name="{{ .ID }}" href="{{ .Url }}">#{{ .ID }}</a>
|
||||
</div>
|
||||
{{ if $.User }}
|
||||
{{ if and $.User $.Project.HasForum }}
|
||||
<div class="flex pr3">
|
||||
{{ if or (eq .Author.ID $.User.ID) $.User.IsStaff }}
|
||||
<a class="delete action button" href="{{ .DeleteUrl }}" title="Delete">✖</a>
|
||||
|
@ -120,11 +120,13 @@
|
|||
<a class="button" href="{{ .SubforumUrl }}">← Back to index</a>
|
||||
{{ if .Thread.Locked }}
|
||||
<span>Thread is locked.</span>
|
||||
{{ else if .User }}
|
||||
{{ else if .Project.HasForum }}
|
||||
{{ if .User }}
|
||||
<a class="button" href="{{ .ReplyUrl }}">⤷ Reply to Thread</a>
|
||||
{{ else }}
|
||||
<span class="pa2"><a href="{{ .LoginPageUrl }}">Log in</a> to reply</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="options order-0 order-last-ns">
|
||||
{{ template "pagination.html" .Pagination }}
|
||||
|
|
|
@ -30,6 +30,7 @@
|
|||
<div data-tmpl="description">
|
||||
Unknown description
|
||||
</div>
|
||||
<div data-tmpl="tags" class="pt2 flex"></div>
|
||||
<div class="i f7 pt2">
|
||||
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
|
||||
</div>
|
||||
|
@ -38,3 +39,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template id="timeline_item_tag">
|
||||
<div data-tmpl="tag" class="bg-theme-dimmer ph2 pv1 br2"></div>
|
||||
</template>
|
|
@ -59,4 +59,14 @@
|
|||
{{ end }}
|
||||
</div>
|
||||
{{ 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>
|
||||
|
|
|
@ -1,11 +1,10 @@
|
|||
{{ template "base.html" . }}
|
||||
|
||||
{{ define "content" }}
|
||||
<div class="content-block no-bg-image">
|
||||
<div>
|
||||
{{ 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="dn db-l"> <!-- desktop carousel -->
|
||||
{{ 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="flex-grow-1 pr3 relative flex flex-column h-100 justify-center">
|
||||
|
@ -25,14 +24,6 @@
|
|||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="db dn-l"> <!-- mobile/tablet carousel -->
|
||||
{{ range $index, $project := . }}
|
||||
<div class="carousel-item-small {{ if eq $index 0 }}active{{ end }}">
|
||||
{{ template "project_card.html" projectcarddata $project "h-100" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-center pv2">
|
||||
{{ range $index, $project := . }}
|
||||
<div
|
||||
|
@ -47,9 +38,13 @@
|
|||
<div class="bg--dim-ns br2">
|
||||
<div class="clear"></div>
|
||||
<div class="optionbar pv2 ph3">
|
||||
<div class="options">
|
||||
<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 class="projectlist ph3">
|
||||
{{ range .Projects }}
|
||||
|
@ -66,40 +61,14 @@
|
|||
</div>
|
||||
<div class="w-100 w-40-l ph3 ph0-ns flex-shrink-0">
|
||||
<div class="ml3-l mt3 mt0-l pa3 bg--dim br2">
|
||||
{{ if not .UserPendingProject }}
|
||||
<div class="content-block new-project p-spaced">
|
||||
<h2>Project submissions are closed</h2>
|
||||
<p>
|
||||
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>.
|
||||
</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 }}
|
||||
<h2>Personal Projects</h2>
|
||||
<p>Many community members have projects of their own that are currently works in progress. Here's a few:</p>
|
||||
{{ range .PersonalProjects }}
|
||||
<div class="mv3">
|
||||
{{ template "project_card.html" projectcarddata . "" }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -24,6 +24,15 @@
|
|||
{{ end }}
|
||||
{{ end }}
|
||||
</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>
|
||||
{{ end }}
|
||||
|
|
|
@ -128,7 +128,6 @@ type Project struct {
|
|||
|
||||
HasBlog bool
|
||||
HasForum bool
|
||||
HasLibrary bool
|
||||
|
||||
UUID string
|
||||
DateApproved time.Time
|
||||
|
@ -269,6 +268,7 @@ type TimelineItem struct {
|
|||
OwnerName string
|
||||
OwnerUrl string
|
||||
|
||||
Tags []Tag
|
||||
Description template.HTML
|
||||
|
||||
PreviewMedia TimelineItemMedia
|
||||
|
@ -331,3 +331,8 @@ type DiscordUser struct {
|
|||
Discriminator string
|
||||
Avatar string
|
||||
}
|
||||
|
||||
type Tag struct {
|
||||
Text string
|
||||
Url string
|
||||
}
|
||||
|
|
|
@ -137,7 +137,7 @@ func AdminApprovalQueue(c *RequestContext) ResponseData {
|
|||
for _, p := range posts {
|
||||
post := templates.PostToTemplate(&p.Post, &p.Author, c.Theme)
|
||||
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{
|
||||
Post: post,
|
||||
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.
|
||||
// If you pass nil, no breadcrumbs will be created.
|
||||
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 templateSession *templates.Session
|
||||
if c.CurrentUser != nil {
|
||||
|
@ -26,10 +31,10 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
|||
notices := getNoticesFromCookie(c)
|
||||
|
||||
if len(breadcrumbs) > 0 {
|
||||
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
|
||||
projectUrl := c.UrlContext.BuildHomepage()
|
||||
if breadcrumbs[0].Url != projectUrl {
|
||||
rootBreadcrumb := templates.Breadcrumb{
|
||||
Name: c.CurrentProject.Name,
|
||||
Name: project.Name,
|
||||
Url: projectUrl,
|
||||
}
|
||||
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
|
||||
|
@ -42,20 +47,20 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
|||
Breadcrumbs: breadcrumbs,
|
||||
|
||||
CurrentUrl: c.FullUrl(),
|
||||
CurrentProjectUrl: hmnurl.BuildProjectHomepage(c.CurrentProject.Slug),
|
||||
CurrentProjectUrl: c.UrlContext.BuildHomepage(),
|
||||
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,
|
||||
Session: templateSession,
|
||||
Notices: notices,
|
||||
|
||||
ReportIssueMailto: "team@handmade.network",
|
||||
|
||||
OpenGraphItems: buildDefaultOpenGraphItems(c.CurrentProject, title),
|
||||
OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
|
||||
|
||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
||||
IsProjectPage: !project.IsHMN(),
|
||||
Header: templates.Header{
|
||||
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
|
||||
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
||||
|
@ -67,7 +72,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
|||
HMNHomepageUrl: hmnurl.BuildHomepage(),
|
||||
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
||||
PodcastUrl: hmnurl.BuildPodcast(),
|
||||
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
|
||||
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
|
||||
LibraryUrl: hmnurl.BuildLibrary(),
|
||||
},
|
||||
Footer: templates.Footer{
|
||||
|
@ -77,7 +82,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
|||
CodeOfConductUrl: hmnurl.BuildCodeOfConduct(),
|
||||
CommunicationGuidelinesUrl: hmnurl.BuildCommunicationGuidelines(),
|
||||
ProjectIndexUrl: hmnurl.BuildProjectIndex(1),
|
||||
ForumsUrl: hmnurl.BuildForum(models.HMNProjectSlug, nil, 1),
|
||||
ForumsUrl: hmnurl.HMNProjectContext.BuildForum(nil, 1),
|
||||
ContactUrl: hmnurl.BuildContactPage(),
|
||||
},
|
||||
}
|
||||
|
@ -86,19 +91,19 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
|||
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
|
||||
}
|
||||
|
||||
if !c.CurrentProject.IsHMN() {
|
||||
if !project.IsHMN() {
|
||||
episodeGuideUrl := ""
|
||||
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug]
|
||||
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[project.Slug]
|
||||
if hasAnnotations {
|
||||
episodeGuideUrl = hmnurl.BuildEpisodeList(c.CurrentProject.Slug, defaultTopic)
|
||||
episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic)
|
||||
}
|
||||
|
||||
baseData.Header.Project = &templates.ProjectHeader{
|
||||
HasForums: c.CurrentProject.ForumEnabled,
|
||||
HasBlog: c.CurrentProject.BlogEnabled,
|
||||
HasForums: project.HasForums(),
|
||||
HasBlog: project.HasBlog(),
|
||||
HasEpisodeGuide: hasAnnotations,
|
||||
ForumsUrl: hmnurl.BuildForum(c.CurrentProject.Slug, nil, 1),
|
||||
BlogUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
|
||||
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
|
||||
BlogUrl: c.UrlContext.BuildBlog(1),
|
||||
EpisodeGuideUrl: episodeGuideUrl,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,7 +35,7 @@ func BlogIndex(c *RequestContext) ResponseData {
|
|||
|
||||
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},
|
||||
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"))
|
||||
}
|
||||
|
||||
numPages := utils.NumPages(numPosts, postsPerPage)
|
||||
numPages := utils.NumPages(numThreads, postsPerPage)
|
||||
page, ok := ParsePageNumber(c, "page", numPages)
|
||||
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{
|
||||
|
@ -63,19 +63,19 @@ func BlogIndex(c *RequestContext) ResponseData {
|
|||
for _, thread := range threads {
|
||||
entries = append(entries, blogIndexEntry{
|
||||
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),
|
||||
Date: thread.FirstPost.PostDate,
|
||||
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
|
||||
if c.CurrentUser != nil {
|
||||
if c.CurrentProject.HasBlog() && c.CurrentUser != nil {
|
||||
isProjectOwner := false
|
||||
owners, err := FetchProjectOwners(c, c.CurrentProject.ID)
|
||||
owners, err := FetchProjectOwners(c.Context(), c.Conn, c.CurrentProject.ID)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch project owners"))
|
||||
}
|
||||
|
@ -97,14 +97,14 @@ func BlogIndex(c *RequestContext) ResponseData {
|
|||
Current: page,
|
||||
Total: numPages,
|
||||
|
||||
FirstUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, 1),
|
||||
LastUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, numPages),
|
||||
PreviousUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page-1, numPages)),
|
||||
NextUrl: hmnurl.BuildBlog(c.CurrentProject.Slug, utils.IntClamp(1, page+1, numPages)),
|
||||
FirstUrl: c.UrlContext.BuildBlog(1),
|
||||
LastUrl: c.UrlContext.BuildBlog(numPages),
|
||||
PreviousUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page-1, numPages)),
|
||||
NextUrl: c.UrlContext.BuildBlog(utils.IntClamp(1, page+1, numPages)),
|
||||
},
|
||||
|
||||
CanCreatePost: canCreate,
|
||||
NewPostUrl: hmnurl.BuildBlogNewThread(c.CurrentProject.Slug),
|
||||
NewPostUrl: c.UrlContext.BuildBlogNewThread(),
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
@ -138,11 +138,11 @@ func BlogThread(c *RequestContext) ResponseData {
|
|||
for _, p := range posts {
|
||||
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -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{
|
||||
Property: "og:description",
|
||||
Value: posts[0].Post.Preview,
|
||||
|
@ -180,7 +180,7 @@ func BlogThread(c *RequestContext) ResponseData {
|
|||
Thread: templates.ThreadToTemplate(&thread),
|
||||
MainPost: templatePosts[0],
|
||||
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()),
|
||||
}, c.Perf)
|
||||
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"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -210,11 +210,11 @@ func BlogNewThread(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
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.SubmitUrl = hmnurl.BuildBlogNewThread(c.CurrentProject.Slug)
|
||||
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
|
||||
editData.SubmitUrl = c.UrlContext.BuildBlogNewThread()
|
||||
editData.SubmitLabel = "Create Post"
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
newThreadUrl := hmnurl.BuildBlogThread(c.CurrentProject.Slug, threadId, title)
|
||||
newThreadUrl := c.UrlContext.BuildBlogThread(threadId, title)
|
||||
return c.Redirect(newThreadUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
@ -301,11 +301,11 @@ func BlogPostEdit(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
title,
|
||||
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
|
||||
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
|
||||
)
|
||||
|
||||
editData := getEditorDataForEdit(c.CurrentUser, baseData, post)
|
||||
editData.SubmitUrl = hmnurl.BuildBlogPostEdit(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
|
||||
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
|
||||
editData.SubmitUrl = c.UrlContext.BuildBlogPostEdit(cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Edited Post"
|
||||
if post.Thread.FirstID != post.Post.ID {
|
||||
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"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -396,14 +396,14 @@ func BlogPostReply(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
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.AddContentVersion(post.CurrentVersion, post.Editor)
|
||||
|
||||
editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost)
|
||||
editData.SubmitUrl = hmnurl.BuildBlogPostReply(c.CurrentProject.Slug, cd.ThreadID, cd.PostID)
|
||||
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
|
||||
editData.SubmitUrl = c.UrlContext.BuildBlogPostReply(cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Reply"
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
newPostUrl := hmnurl.BuildBlogPost(c.CurrentProject.Slug, cd.ThreadID, newPostId)
|
||||
newPostUrl := c.UrlContext.BuildBlogPost(cd.ThreadID, newPostId)
|
||||
return c.Redirect(newPostUrl, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
|
@ -472,7 +472,7 @@ func BlogPostDelete(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
title,
|
||||
BlogThreadBreadcrumbs(c.CurrentProject.Slug, &post.Thread),
|
||||
BlogThreadBreadcrumbs(c.UrlContext, &post.Thread),
|
||||
)
|
||||
|
||||
templatePost := templates.PostToTemplate(&post.Post, post.Author, c.Theme)
|
||||
|
@ -487,7 +487,7 @@ func BlogPostDelete(c *RequestContext) ResponseData {
|
|||
var res ResponseData
|
||||
res.MustWriteTemplate("blog_post_delete.html", blogPostDeleteData{
|
||||
BaseData: baseData,
|
||||
SubmitUrl: hmnurl.BuildBlogPostDelete(c.CurrentProject.Slug, cd.ThreadID, cd.PostID),
|
||||
SubmitUrl: c.UrlContext.BuildBlogPostDelete(cd.ThreadID, cd.PostID),
|
||||
Post: templatePost,
|
||||
}, c.Perf)
|
||||
return res
|
||||
|
@ -517,8 +517,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
if threadDeleted {
|
||||
projectUrl := hmnurl.BuildProjectHomepage(c.CurrentProject.Slug)
|
||||
return c.Redirect(projectUrl, http.StatusSeeOther)
|
||||
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||
} else {
|
||||
thread, err := FetchThread(c.Context(), c.Conn, c.CurrentUser, cd.ThreadID, ThreadsQuery{
|
||||
ProjectIDs: []int{c.CurrentProject.ID},
|
||||
|
@ -529,7 +528,7 @@ func BlogPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
} else if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@ -608,9 +607,9 @@ func getCommonBlogData(c *RequestContext) (commonBlogData, bool) {
|
|||
return res, true
|
||||
}
|
||||
|
||||
func addBlogUrlsToPost(p *templates.Post, projectSlug string, thread *models.Thread, postId int) {
|
||||
p.Url = hmnurl.BuildBlogThreadWithPostHash(projectSlug, thread.ID, thread.Title, postId)
|
||||
p.DeleteUrl = hmnurl.BuildBlogPostDelete(projectSlug, thread.ID, postId)
|
||||
p.EditUrl = hmnurl.BuildBlogPostEdit(projectSlug, thread.ID, postId)
|
||||
p.ReplyUrl = hmnurl.BuildBlogPostReply(projectSlug, thread.ID, postId)
|
||||
func addBlogUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, thread *models.Thread, postId int) {
|
||||
p.Url = urlContext.BuildBlogThreadWithPostHash(thread.ID, thread.Title, postId)
|
||||
p.DeleteUrl = urlContext.BuildBlogPostDelete(thread.ID, postId)
|
||||
p.EditUrl = urlContext.BuildBlogPostEdit(thread.ID, postId)
|
||||
p.ReplyUrl = urlContext.BuildBlogPostReply(thread.ID, postId)
|
||||
}
|
||||
|
|
|
@ -6,58 +6,58 @@ import (
|
|||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
)
|
||||
|
||||
func ProjectBreadcrumb(project *models.Project) templates.Breadcrumb {
|
||||
func ProjectBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
|
||||
return templates.Breadcrumb{
|
||||
Name: project.Name,
|
||||
Url: hmnurl.BuildProjectHomepage(project.Slug),
|
||||
Name: projectUrlContext.ProjectName,
|
||||
Url: projectUrlContext.BuildHomepage(),
|
||||
}
|
||||
}
|
||||
|
||||
func ForumBreadcrumb(projectSlug string) templates.Breadcrumb {
|
||||
func ForumBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
|
||||
return templates.Breadcrumb{
|
||||
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
|
||||
result = []templates.Breadcrumb{
|
||||
ProjectBreadcrumb(project),
|
||||
ForumBreadcrumb(project.Slug),
|
||||
ProjectBreadcrumb(projectUrlContext),
|
||||
ForumBreadcrumb(projectUrlContext),
|
||||
}
|
||||
subforums := lineageBuilder.GetSubforumLineage(subforumID)
|
||||
slugs := lineageBuilder.GetSubforumLineageSlugs(subforumID)
|
||||
for i, subforum := range subforums {
|
||||
result = append(result, templates.Breadcrumb{
|
||||
Name: subforum.Name,
|
||||
Url: hmnurl.BuildForum(project.Slug, slugs[0:i+1], 1),
|
||||
Url: projectUrlContext.BuildForum(slugs[0:i+1], 1),
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func ForumThreadBreadcrumbs(lineageBuilder *models.SubforumLineageBuilder, project *models.Project, thread *models.Thread) []templates.Breadcrumb {
|
||||
result := SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID)
|
||||
func ForumThreadBreadcrumbs(projectUrlContext *hmnurl.UrlContext, lineageBuilder *models.SubforumLineageBuilder, thread *models.Thread) []templates.Breadcrumb {
|
||||
result := SubforumBreadcrumbs(projectUrlContext, lineageBuilder, *thread.SubforumID)
|
||||
result = append(result, templates.Breadcrumb{
|
||||
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
|
||||
}
|
||||
|
||||
func BlogBreadcrumb(projectSlug string) templates.Breadcrumb {
|
||||
func BlogBreadcrumb(projectUrlContext *hmnurl.UrlContext) templates.Breadcrumb {
|
||||
return templates.Breadcrumb{
|
||||
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{
|
||||
BlogBreadcrumb(projectSlug),
|
||||
{Name: thread.Title, Url: hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)},
|
||||
BlogBreadcrumb(projectUrlContext),
|
||||
{Name: thread.Title, Url: projectUrlContext.BuildBlogThread(thread.ID, thread.Title)},
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
|
|
@ -11,7 +11,6 @@ import (
|
|||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/config"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/templates"
|
||||
)
|
||||
|
||||
|
@ -53,11 +52,11 @@ func EpisodeList(c *RequestContext) ResponseData {
|
|||
defaultTopic, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||
|
||||
if !hasEpisodeGuide {
|
||||
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
||||
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
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)
|
||||
|
@ -82,7 +81,7 @@ func EpisodeList(c *RequestContext) ResponseData {
|
|||
for _, t := range allTopics {
|
||||
url := ""
|
||||
if t != foundTopic {
|
||||
url = hmnurl.BuildEpisodeList(slug, t)
|
||||
url = c.UrlContext.BuildEpisodeList(t)
|
||||
}
|
||||
topicLinks = append(topicLinks, templates.Link{LinkText: t, Url: url})
|
||||
}
|
||||
|
@ -114,7 +113,7 @@ func Episode(c *RequestContext) ResponseData {
|
|||
_, hasEpisodeGuide := config.Config.EpisodeGuide.Projects[slug]
|
||||
|
||||
if !hasEpisodeGuide {
|
||||
return c.Redirect(hmnurl.BuildProjectHomepage(slug), http.StatusSeeOther)
|
||||
return c.Redirect(c.UrlContext.BuildHomepage(), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
_, foundTopic := topicsForProject(slug, topic)
|
||||
|
@ -150,7 +149,7 @@ func Episode(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
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{
|
||||
BaseData: baseData,
|
||||
|
|
|
@ -71,7 +71,7 @@ func Feed(c *RequestContext) ResponseData {
|
|||
BaseData: baseData,
|
||||
|
||||
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
||||
MarkAllReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, 0),
|
||||
MarkAllReadUrl: c.UrlContext.BuildForumMarkRead(0),
|
||||
Posts: posts,
|
||||
Pagination: pagination,
|
||||
}, c.Perf)
|
||||
|
@ -167,7 +167,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
handmade_project AS project
|
||||
WHERE
|
||||
project.lifecycle = ANY($1)
|
||||
AND project.flags = 0
|
||||
AND NOT project.hidden
|
||||
ORDER BY date_approved DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
|
@ -181,7 +181,7 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
projectMap := make(map[int]int) // map[project id]index in slice
|
||||
for _, p := range projects.ToSlice() {
|
||||
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()
|
||||
|
||||
projectIds = append(projectIds, project.ID)
|
||||
|
@ -228,35 +228,14 @@ func AtomFeed(c *RequestContext) ResponseData {
|
|||
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
|
||||
feedData.FeedUrl = hmnurl.BuildShowcase()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
||||
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
|
||||
LIMIT $1
|
||||
`,
|
||||
itemsPerFeed,
|
||||
)
|
||||
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||
Limit: itemsPerFeed,
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||
}
|
||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
||||
for _, s := range snippetQuerySlice {
|
||||
row := s.(*snippetQuery)
|
||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
||||
}
|
||||
c.Perf.EndBlock()
|
||||
|
|
|
@ -35,8 +35,6 @@ type forumSubforumData struct {
|
|||
TotalThreads int
|
||||
}
|
||||
|
||||
type editActionType string
|
||||
|
||||
type editorData struct {
|
||||
templates.BaseData
|
||||
SubmitUrl string
|
||||
|
@ -54,13 +52,13 @@ type editorData struct {
|
|||
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{
|
||||
BaseData: baseData,
|
||||
CanEditTitle: replyPost == nil,
|
||||
PostReplyingTo: replyPost,
|
||||
MaxFileSize: AssetMaxSize(currentUser),
|
||||
UploadUrl: hmnurl.BuildAssetUpload(baseData.Project.Subdomain),
|
||||
UploadUrl: urlContext.BuildAssetUpload(),
|
||||
}
|
||||
|
||||
if replyPost != nil {
|
||||
|
@ -70,7 +68,7 @@ func getEditorDataForNew(currentUser *models.User, baseData templates.BaseData,
|
|||
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{
|
||||
BaseData: baseData,
|
||||
Title: p.Thread.Title,
|
||||
|
@ -78,7 +76,7 @@ func getEditorDataForEdit(currentUser *models.User, baseData templates.BaseData,
|
|||
IsEditing: true,
|
||||
EditInitialContents: p.CurrentVersion.TextRaw,
|
||||
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)
|
||||
page, ok := ParsePageNumber(c, "page", numPages)
|
||||
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
|
||||
|
||||
|
@ -119,7 +117,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
makeThreadListItem := func(row ThreadAndStuff) templates.ThreadListItem {
|
||||
return templates.ThreadListItem{
|
||||
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),
|
||||
FirstDate: row.FirstPost.PostDate,
|
||||
LastUser: templates.UserToTemplate(row.LastPostAuthor, c.Theme),
|
||||
|
@ -165,7 +163,7 @@ func Forum(c *RequestContext) ResponseData {
|
|||
|
||||
subforums = append(subforums, forumSubforumData{
|
||||
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,
|
||||
TotalThreads: numThreads,
|
||||
})
|
||||
|
@ -179,23 +177,23 @@ func Forum(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
fmt.Sprintf("%s Forums", c.CurrentProject.Name),
|
||||
SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID),
|
||||
SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID),
|
||||
)
|
||||
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("forum.html", forumData{
|
||||
BaseData: baseData,
|
||||
NewThreadUrl: hmnurl.BuildForumNewThread(c.CurrentProject.Slug, currentSubforumSlugs, false),
|
||||
MarkReadUrl: hmnurl.BuildForumMarkRead(c.CurrentProject.Slug, cd.SubforumID),
|
||||
NewThreadUrl: c.UrlContext.BuildForumNewThread(currentSubforumSlugs, false),
|
||||
MarkReadUrl: c.UrlContext.BuildForumMarkRead(cd.SubforumID),
|
||||
Threads: threads,
|
||||
Pagination: templates.Pagination{
|
||||
Current: page,
|
||||
Total: numPages,
|
||||
|
||||
FirstUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
|
||||
LastUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, numPages),
|
||||
NextUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
|
||||
PreviousUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
|
||||
FirstUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
|
||||
LastUrl: c.UrlContext.BuildForum(currentSubforumSlugs, numPages),
|
||||
NextUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page+1, numPages)),
|
||||
PreviousUrl: c.UrlContext.BuildForum(currentSubforumSlugs, utils.IntClamp(1, page-1, numPages)),
|
||||
},
|
||||
Subforums: subforums,
|
||||
}, c.Perf)
|
||||
|
@ -308,7 +306,7 @@ func ForumMarkRead(c *RequestContext) ResponseData {
|
|||
if sfId == 0 {
|
||||
redirUrl = hmnurl.BuildFeed()
|
||||
} 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)
|
||||
}
|
||||
|
@ -358,17 +356,17 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
}
|
||||
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, threadPostsPerPage)
|
||||
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)
|
||||
}
|
||||
pagination := templates.Pagination{
|
||||
Current: page,
|
||||
Total: numPages,
|
||||
|
||||
FirstUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, 1),
|
||||
LastUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.Title, numPages),
|
||||
NextUrl: hmnurl.BuildForumThread(c.CurrentProject.Slug, 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)),
|
||||
FirstUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, 1),
|
||||
LastUrl: c.UrlContext.BuildForumThread(currentSubforumSlugs, thread.ID, thread.Title, numPages),
|
||||
NextUrl: c.UrlContext.BuildForumThread(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{
|
||||
|
@ -385,11 +383,11 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
for _, p := range postsAndStuff {
|
||||
post := templates.PostToTemplate(&p.Post, p.Author, c.Theme)
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
@ -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{
|
||||
Property: "og:description",
|
||||
Value: threadResult.FirstPost.Preview,
|
||||
|
@ -429,8 +427,8 @@ func ForumThread(c *RequestContext) ResponseData {
|
|||
BaseData: baseData,
|
||||
Thread: templates.ThreadToTemplate(&thread),
|
||||
Posts: posts,
|
||||
SubforumUrl: hmnurl.BuildForum(c.CurrentProject.Slug, currentSubforumSlugs, 1),
|
||||
ReplyUrl: hmnurl.BuildForumPostReply(c.CurrentProject.Slug, currentSubforumSlugs, thread.ID, thread.FirstID),
|
||||
SubforumUrl: c.UrlContext.BuildForum(currentSubforumSlugs, 1),
|
||||
ReplyUrl: c.UrlContext.BuildForumPostReply(currentSubforumSlugs, thread.ID, thread.FirstID),
|
||||
Pagination: pagination,
|
||||
}, c.Perf)
|
||||
return res
|
||||
|
@ -466,8 +464,7 @@ func ForumPostRedirect(c *RequestContext) ResponseData {
|
|||
|
||||
page := (postIdx / threadPostsPerPage) + 1
|
||||
|
||||
return c.Redirect(hmnurl.BuildForumThreadWithPostHash(
|
||||
c.CurrentProject.Slug,
|
||||
return c.Redirect(c.UrlContext.BuildForumThreadWithPostHash(
|
||||
cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID),
|
||||
cd.ThreadID,
|
||||
post.Thread.Title,
|
||||
|
@ -482,9 +479,9 @@ func ForumNewThread(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(cd.LineageBuilder, c.CurrentProject, cd.SubforumID))
|
||||
editData := getEditorDataForNew(c.CurrentUser, baseData, nil)
|
||||
editData.SubmitUrl = hmnurl.BuildForumNewThread(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
|
||||
baseData := getBaseData(c, "Create New Thread", SubforumBreadcrumbs(c.UrlContext, cd.LineageBuilder, cd.SubforumID))
|
||||
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, nil)
|
||||
editData.SubmitUrl = c.UrlContext.BuildForumNewThread(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), true)
|
||||
editData.SubmitLabel = "Post New Thread"
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -572,14 +569,14 @@ func ForumPostReply(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
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.AddContentVersion(post.CurrentVersion, post.Editor)
|
||||
|
||||
editData := getEditorDataForNew(c.CurrentUser, baseData, &replyPost)
|
||||
editData.SubmitUrl = hmnurl.BuildForumPostReply(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||
editData := getEditorDataForNew(c.UrlContext, c.CurrentUser, baseData, &replyPost)
|
||||
editData.SubmitUrl = c.UrlContext.BuildForumPostReply(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Reply"
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -659,10 +656,10 @@ func ForumPostEdit(c *RequestContext) ResponseData {
|
|||
} else {
|
||||
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.SubmitUrl = hmnurl.BuildForumPostEdit(c.CurrentProject.Slug, cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||
editData := getEditorDataForEdit(c.UrlContext, c.CurrentUser, baseData, post)
|
||||
editData.SubmitUrl = c.UrlContext.BuildForumPostEdit(cd.LineageBuilder.GetSubforumLineageSlugs(cd.SubforumID), cd.ThreadID, cd.PostID)
|
||||
editData.SubmitLabel = "Submit Edited Post"
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
|
@ -754,7 +751,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
|
|||
baseData := getBaseData(
|
||||
c,
|
||||
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)
|
||||
|
@ -769,7 +766,7 @@ func ForumPostDelete(c *RequestContext) ResponseData {
|
|||
var res ResponseData
|
||||
res.MustWriteTemplate("forum_post_delete.html", forumPostDeleteData{
|
||||
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,
|
||||
}, c.Perf)
|
||||
return res
|
||||
|
@ -799,10 +796,10 @@ func ForumPostDeleteSubmit(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
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)
|
||||
} 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)
|
||||
}
|
||||
}
|
||||
|
@ -829,7 +826,7 @@ func WikiArticleRedirect(c *RequestContext) ResponseData {
|
|||
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
|
||||
c.Perf.EndBlock()
|
||||
|
||||
dest := UrlForGenericThread(&thread.Thread, lineageBuilder, c.CurrentProject.Slug)
|
||||
dest := UrlForGenericThread(c.UrlContext, &thread.Thread, lineageBuilder)
|
||||
return c.Redirect(dest, http.StatusFound)
|
||||
}
|
||||
|
||||
|
@ -928,11 +925,11 @@ func validateSubforums(lineageBuilder *models.SubforumLineageBuilder, project *m
|
|||
return subforumId, valid
|
||||
}
|
||||
|
||||
func addForumUrlsToPost(p *templates.Post, projectSlug string, subforums []string, threadId int, postId int) {
|
||||
p.Url = hmnurl.BuildForumPost(projectSlug, subforums, threadId, postId)
|
||||
p.DeleteUrl = hmnurl.BuildForumPostDelete(projectSlug, subforums, threadId, postId)
|
||||
p.EditUrl = hmnurl.BuildForumPostEdit(projectSlug, subforums, threadId, postId)
|
||||
p.ReplyUrl = hmnurl.BuildForumPostReply(projectSlug, subforums, threadId, postId)
|
||||
func addForumUrlsToPost(urlContext *hmnurl.UrlContext, p *templates.Post, subforums []string, threadId int, postId int) {
|
||||
p.Url = urlContext.BuildForumPost(subforums, threadId, postId)
|
||||
p.DeleteUrl = urlContext.BuildForumPostDelete(subforums, threadId, postId)
|
||||
p.EditUrl = urlContext.BuildForumPostEdit(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
|
||||
|
|
|
@ -4,9 +4,7 @@ import (
|
|||
"net/http"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"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/templates"
|
||||
)
|
||||
|
@ -20,35 +18,25 @@ func JamIndex(c *RequestContext) ResponseData {
|
|||
daysUntil = 0
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
||||
type snippetQuery struct {
|
||||
Owner models.User `db:"owner"`
|
||||
Snippet models.Snippet `db:"snippet"`
|
||||
Asset *models.Asset `db:"asset"`
|
||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
||||
tagId := -1
|
||||
jamTag, err := FetchTag(c.Context(), c.Conn, TagQuery{
|
||||
Text: []string{"wheeljam"},
|
||||
})
|
||||
if err == nil {
|
||||
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{},
|
||||
`
|
||||
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.is_jam
|
||||
ORDER BY snippet.when DESC
|
||||
LIMIT 20
|
||||
`,
|
||||
)
|
||||
|
||||
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 jam snippets"))
|
||||
}
|
||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
||||
for _, s := range snippetQuerySlice {
|
||||
row := s.(*snippetQuery)
|
||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
|
|
|
@ -5,7 +5,6 @@ import (
|
|||
"math"
|
||||
"net/http"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"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")
|
||||
}
|
||||
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 {
|
||||
// blog post
|
||||
item.Description = template.HTML(p.CurrentVersion.TextParsed)
|
||||
|
@ -95,7 +94,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
var newsPostItem *templates.TimelineItem
|
||||
if len(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.Breadcrumbs = nil
|
||||
item.TypeTitle = ""
|
||||
|
@ -106,35 +105,15 @@ func Index(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
||||
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
|
||||
LIMIT 40
|
||||
`,
|
||||
)
|
||||
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||
Limit: 40,
|
||||
})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||
}
|
||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
||||
for _, s := range snippetQuerySlice {
|
||||
row := s.(*snippetQuery)
|
||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
|
@ -167,7 +146,7 @@ func Index(c *RequestContext) ResponseData {
|
|||
StreamsUrl: hmnurl.BuildStreams(),
|
||||
ShowcaseUrl: hmnurl.BuildShowcase(),
|
||||
AtomFeedUrl: hmnurl.BuildAtomFeed(),
|
||||
MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0),
|
||||
MarkAllReadUrl: hmnurl.HMNProjectContext.BuildForumMarkRead(0),
|
||||
|
||||
WheelJamUrl: hmnurl.BuildJamIndex(),
|
||||
}, 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.
|
||||
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 {
|
||||
case models.ThreadTypeProjectBlogPost:
|
||||
return hmnurl.BuildBlogThread(projectSlug, thread.ID, thread.Title)
|
||||
return urlContext.BuildBlogThread(thread.ID, thread.Title)
|
||||
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 {
|
||||
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:
|
||||
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{
|
||||
|
@ -47,33 +47,33 @@ var ThreadTypeDisplayNames = map[models.ThreadType]string{
|
|||
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
|
||||
if thread.Type == models.ThreadTypeForumPost {
|
||||
result = SubforumBreadcrumbs(lineageBuilder, project, *thread.SubforumID)
|
||||
result = SubforumBreadcrumbs(urlContext, lineageBuilder, *thread.SubforumID)
|
||||
} else {
|
||||
result = []templates.Breadcrumb{
|
||||
{
|
||||
Name: project.Name,
|
||||
Url: hmnurl.BuildProjectHomepage(project.Slug),
|
||||
Name: urlContext.ProjectName,
|
||||
Url: urlContext.BuildHomepage(),
|
||||
},
|
||||
{
|
||||
Name: ThreadTypeDisplayNames[thread.Type],
|
||||
Url: BuildProjectRootResourceUrl(project.Slug, thread.Type),
|
||||
Url: BuildProjectRootResourceUrl(urlContext, thread.Type),
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func BuildProjectRootResourceUrl(projectSlug string, kind models.ThreadType) string {
|
||||
func BuildProjectRootResourceUrl(urlContext *hmnurl.UrlContext, kind models.ThreadType) string {
|
||||
switch kind {
|
||||
case models.ThreadTypeProjectBlogPost:
|
||||
return hmnurl.BuildBlog(projectSlug, 1)
|
||||
return urlContext.BuildBlog(1)
|
||||
case models.ThreadTypeForumPost:
|
||||
return hmnurl.BuildForum(projectSlug, nil, 1)
|
||||
return urlContext.BuildForum(nil, 1)
|
||||
}
|
||||
return hmnurl.BuildProjectHomepage(projectSlug)
|
||||
return urlContext.BuildHomepage()
|
||||
}
|
||||
|
||||
func MakePostListItem(
|
||||
|
@ -88,11 +88,13 @@ func MakePostListItem(
|
|||
) templates.PostListItem {
|
||||
var result templates.PostListItem
|
||||
|
||||
urlContext := UrlContextForProject(project)
|
||||
|
||||
result.Title = thread.Title
|
||||
result.User = templates.UserToTemplate(user, currentTheme)
|
||||
result.Date = post.PostDate
|
||||
result.Unread = unread
|
||||
result.Url = UrlForGenericPost(thread, post, lineageBuilder, project.Slug)
|
||||
result.Url = UrlForGenericPost(urlContext, thread, post, lineageBuilder)
|
||||
result.Preview = post.Preview
|
||||
|
||||
postType := templates.PostTypeUnknown
|
||||
|
@ -108,7 +110,7 @@ func MakePostListItem(
|
|||
result.PostTypePrefix = PostTypePrefix[result.PostType]
|
||||
|
||||
if includeBreadcrumbs {
|
||||
result.Breadcrumbs = GenericThreadBreadcrumbs(lineageBuilder, project, thread)
|
||||
result.Breadcrumbs = GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread)
|
||||
}
|
||||
|
||||
return result
|
||||
|
|
|
@ -1,17 +1,246 @@
|
|||
package website
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"git.handmade.network/hmn/hmn/src/models"
|
||||
"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) {
|
||||
if user != nil {
|
||||
if user.IsStaff {
|
||||
return true, nil
|
||||
} else {
|
||||
owners, err := FetchProjectOwners(c, projectId)
|
||||
owners, err := FetchProjectOwners(c.Context(), c.Conn, projectId)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
@ -25,29 +254,146 @@ func CanEditProject(c *RequestContext, user *models.User, projectId int) (bool,
|
|||
return false, nil
|
||||
}
|
||||
|
||||
func FetchProjectOwners(c *RequestContext, projectId int) ([]*models.User, error) {
|
||||
var result []*models.User
|
||||
c.Perf.StartBlock("SQL", "Fetching project owners")
|
||||
type ownerQuery struct {
|
||||
Owner models.User `db:"auth_user"`
|
||||
type ProjectOwners struct {
|
||||
ProjectID int
|
||||
Owners []*models.User
|
||||
}
|
||||
ownerQueryResult, err := db.Query(c.Context(), c.Conn, ownerQuery{},
|
||||
|
||||
// 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")
|
||||
}
|
||||
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
|
||||
FROM
|
||||
auth_user
|
||||
INNER JOIN handmade_user_projects AS uproj ON uproj.user_id = auth_user.id
|
||||
WHERE
|
||||
uproj.project_id = $1
|
||||
FROM handmade_user_projects
|
||||
WHERE project_id = ANY($1)
|
||||
`,
|
||||
projectId,
|
||||
projectIds,
|
||||
)
|
||||
c.Perf.EndBlock()
|
||||
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() {
|
||||
result = append(result, &ownerRow.(*ownerQuery).Owner)
|
||||
iuserprojects := it.ToSlice()
|
||||
|
||||
// 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
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
|
@ -24,10 +22,7 @@ type ProjectTemplateData struct {
|
|||
Pagination templates.Pagination
|
||||
CarouselProjects []templates.Project
|
||||
Projects []templates.Project
|
||||
|
||||
UserPendingProjectUnderReview bool
|
||||
UserPendingProject *templates.Project
|
||||
UserApprovedProjects []templates.Project
|
||||
PersonalProjects []templates.Project
|
||||
|
||||
ProjectAtomFeedUrl string
|
||||
WIPForumUrl string
|
||||
|
@ -36,47 +31,19 @@ type ProjectTemplateData struct {
|
|||
func ProjectIndex(c *RequestContext) ResponseData {
|
||||
const projectsPerPage = 20
|
||||
const maxCarouselProjects = 10
|
||||
const maxPersonalProjects = 10
|
||||
|
||||
page := 1
|
||||
pageString, hasPage := c.PathParams["page"]
|
||||
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,
|
||||
)
|
||||
officialProjects, err := FetchProjects(c.Context(), c.Conn, c.CurrentUser, ProjectsQuery{
|
||||
Types: OfficialProjects,
|
||||
})
|
||||
if err != nil {
|
||||
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))
|
||||
|
||||
if page > numPages {
|
||||
return c.Redirect(hmnurl.BuildProjectIndex(numPages), http.StatusSeeOther)
|
||||
numPages := int(math.Ceil(float64(len(officialProjects)) / projectsPerPage))
|
||||
page, numPages, ok := getPageInfo(c.PathParams["page"], len(officialProjects), feedPostsPerPage)
|
||||
if !ok {
|
||||
return c.Redirect(hmnurl.BuildProjectIndex(1), http.StatusSeeOther)
|
||||
}
|
||||
|
||||
pagination := templates.Pagination{
|
||||
|
@ -89,63 +56,22 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
|||
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")
|
||||
var handmadeHero *templates.Project
|
||||
var featuredProjects []templates.Project
|
||||
var recentProjects []templates.Project
|
||||
var restProjects []templates.Project
|
||||
now := time.Now()
|
||||
for _, p := range allProjectsSlice {
|
||||
project := &p.(*projectResult).Project
|
||||
templateProject := templates.ProjectToTemplate(project, c.Theme)
|
||||
if project.Slug == "hero" {
|
||||
for _, p := range officialProjects {
|
||||
templateProject := templates.ProjectToTemplate(&p.Project, UrlContextForProject(&p.Project).BuildHomepage(), c.Theme)
|
||||
if p.Project.Slug == "hero" {
|
||||
// NOTE(asaf): Handmade Hero gets special treatment. Must always be first in the list.
|
||||
handmadeHero = &templateProject
|
||||
continue
|
||||
}
|
||||
if project.Featured {
|
||||
if p.Project.Featured {
|
||||
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)
|
||||
} else {
|
||||
restProjects = append(restProjects, templateProject)
|
||||
|
@ -178,6 +104,32 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
|||
}
|
||||
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")
|
||||
var res ResponseData
|
||||
res.MustWriteTemplate("project_index.html", ProjectTemplateData{
|
||||
|
@ -186,13 +138,10 @@ func ProjectIndex(c *RequestContext) ResponseData {
|
|||
Pagination: pagination,
|
||||
CarouselProjects: carouselProjects,
|
||||
Projects: pageProjects,
|
||||
|
||||
UserPendingProjectUnderReview: userPendingProjectUnderReview,
|
||||
UserPendingProject: userPendingProject,
|
||||
UserApprovedProjects: userApprovedProjects,
|
||||
PersonalProjects: personalProjects,
|
||||
|
||||
ProjectAtomFeedUrl: hmnurl.BuildAtomFeedForProjects(),
|
||||
WIPForumUrl: hmnurl.BuildForum(models.HMNProjectSlug, []string{"wip"}, 1),
|
||||
WIPForumUrl: hmnurl.HMNProjectContext.BuildForum([]string{"wip"}, 1),
|
||||
}, c.Perf)
|
||||
return res
|
||||
}
|
||||
|
@ -209,86 +158,19 @@ type ProjectHomepageData struct {
|
|||
|
||||
func ProjectHomepage(c *RequestContext) ResponseData {
|
||||
maxRecentActivity := 15
|
||||
var project *models.Project
|
||||
|
||||
if c.CurrentProject.IsHMN() {
|
||||
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 {
|
||||
if c.CurrentProject == nil {
|
||||
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 {
|
||||
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")
|
||||
type screenshotQuery struct {
|
||||
Filename string `db:"screenshot.file"`
|
||||
|
@ -302,7 +184,7 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
WHERE
|
||||
handmade_project_screenshots.project_id = $1
|
||||
`,
|
||||
project.ID,
|
||||
c.CurrentProject.ID,
|
||||
)
|
||||
if err != nil {
|
||||
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
|
||||
ORDER BY link.ordering ASC
|
||||
`,
|
||||
project.ID,
|
||||
c.CurrentProject.ID,
|
||||
)
|
||||
if err != nil {
|
||||
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
|
||||
LIMIT $2
|
||||
`,
|
||||
project.ID,
|
||||
c.CurrentProject.ID,
|
||||
maxRecentActivity,
|
||||
)
|
||||
if err != nil {
|
||||
|
@ -360,62 +242,62 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
var projectHomepageData ProjectHomepageData
|
||||
var templateData ProjectHomepageData
|
||||
|
||||
projectHomepageData.BaseData = getBaseData(c, project.Name, nil)
|
||||
if canEdit {
|
||||
// TODO: Move to project-specific navigation
|
||||
// projectHomepageData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
||||
}
|
||||
projectHomepageData.BaseData.OpenGraphItems = append(projectHomepageData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
||||
templateData.BaseData = getBaseData(c, c.CurrentProject.Name, nil)
|
||||
//if canEdit {
|
||||
// // TODO: Move to project-specific navigation
|
||||
// // templateData.BaseData.Header.EditURL = hmnurl.BuildProjectEdit(project.Slug, "")
|
||||
//}
|
||||
templateData.BaseData.OpenGraphItems = append(templateData.BaseData.OpenGraphItems, templates.OpenGraphItem{
|
||||
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 {
|
||||
projectHomepageData.Owners = append(projectHomepageData.Owners, templates.UserToTemplate(owner, c.Theme))
|
||||
templateData.Owners = append(templateData.Owners, templates.UserToTemplate(owner, c.Theme))
|
||||
}
|
||||
|
||||
if project.Flags == 1 {
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
if c.CurrentProject.Hidden {
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"hidden",
|
||||
"NOTICE: This project is hidden. It is currently visible only to owners and site admins.",
|
||||
)
|
||||
}
|
||||
|
||||
if project.Lifecycle != models.ProjectLifecycleActive {
|
||||
switch project.Lifecycle {
|
||||
if c.CurrentProject.Lifecycle != models.ProjectLifecycleActive {
|
||||
switch c.CurrentProject.Lifecycle {
|
||||
case models.ProjectLifecycleUnapproved:
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"unapproved",
|
||||
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.",
|
||||
hmnurl.BuildProjectEdit(project.Slug, "submit"),
|
||||
c.UrlContext.BuildProjectEdit("submit"),
|
||||
),
|
||||
)
|
||||
case models.ProjectLifecycleApprovalRequired:
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"unapproved",
|
||||
"NOTICE: This project is awaiting approval. It is only visible to owners and site admins.",
|
||||
)
|
||||
case models.ProjectLifecycleHiatus:
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"hiatus",
|
||||
"NOTICE: This project is on hiatus and may not update for a while.",
|
||||
)
|
||||
case models.ProjectLifecycleDead:
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"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.",
|
||||
)
|
||||
case models.ProjectLifecycleLTSRequired:
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"lts-reqd",
|
||||
"NOTICE: This project is awaiting approval for maintenance-mode status.",
|
||||
)
|
||||
case models.ProjectLifecycleLTS:
|
||||
projectHomepageData.BaseData.AddImmediateNotice(
|
||||
templateData.BaseData.AddImmediateNotice(
|
||||
"lts",
|
||||
"NOTICE: This project has reached a state of completion.",
|
||||
)
|
||||
|
@ -423,26 +305,56 @@ func ProjectHomepage(c *RequestContext) ResponseData {
|
|||
}
|
||||
|
||||
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() {
|
||||
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() {
|
||||
projectHomepageData.RecentActivity = append(projectHomepageData.RecentActivity, PostToTimelineItem(
|
||||
templateData.RecentActivity = append(templateData.RecentActivity, PostToTimelineItem(
|
||||
c.UrlContext,
|
||||
lineageBuilder,
|
||||
&post.(*postQuery).Post,
|
||||
&post.(*postQuery).Thread,
|
||||
project,
|
||||
&post.(*postQuery).Author,
|
||||
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
|
||||
err = res.WriteTemplate("project_homepage.html", projectHomepageData, c.Perf)
|
||||
err = res.WriteTemplate("project_homepage.html", templateData, c.Perf)
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to render project homepage template"))
|
||||
}
|
||||
|
|
|
@ -30,12 +30,21 @@ type Router struct {
|
|||
|
||||
type Route struct {
|
||||
Method string
|
||||
Regex *regexp.Regexp
|
||||
Regexes []*regexp.Regexp
|
||||
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 {
|
||||
Router *Router
|
||||
Prefixes []*regexp.Regexp
|
||||
Middleware Middleware
|
||||
}
|
||||
|
||||
|
@ -44,11 +53,17 @@ type Handler func(c *RequestContext) ResponseData
|
|||
type Middleware func(h Handler) 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)
|
||||
for _, method := range methods {
|
||||
rb.Router.Routes = append(rb.Router.Routes, Route{
|
||||
Method: method,
|
||||
Regex: regex,
|
||||
Regexes: append(rb.Prefixes, regex),
|
||||
Handler: h,
|
||||
})
|
||||
}
|
||||
|
@ -66,49 +81,72 @@ func (rb *RouteBuilder) POST(regex *regexp.Regexp, h Handler) {
|
|||
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) {
|
||||
path := req.URL.Path
|
||||
nextroute:
|
||||
for _, route := range r.Routes {
|
||||
if route.Method != "" && req.Method != route.Method {
|
||||
continue
|
||||
}
|
||||
|
||||
path = strings.TrimSuffix(path, "/")
|
||||
if path == "" {
|
||||
path = "/"
|
||||
currentPath := strings.TrimSuffix(req.URL.Path, "/")
|
||||
if currentPath == "" {
|
||||
currentPath = "/"
|
||||
}
|
||||
|
||||
match := route.Regex.FindStringSubmatch(path)
|
||||
if match == nil {
|
||||
continue
|
||||
var params map[string]string
|
||||
for _, regex := range route.Regexes {
|
||||
match := regex.FindStringSubmatch(currentPath)
|
||||
if len(match) == 0 {
|
||||
continue nextroute
|
||||
}
|
||||
|
||||
c := &RequestContext{
|
||||
Route: route.Regex.String(),
|
||||
Logger: logging.GlobalLogger(),
|
||||
Req: req,
|
||||
Res: rw,
|
||||
if params == nil {
|
||||
params = map[string]string{}
|
||||
}
|
||||
|
||||
if len(match) > 0 {
|
||||
params := map[string]string{}
|
||||
subexpNames := route.Regex.SubexpNames()
|
||||
subexpNames := regex.SubexpNames()
|
||||
for i, paramValue := range match {
|
||||
paramName := subexpNames[i]
|
||||
if paramName == "" {
|
||||
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
|
||||
}
|
||||
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)
|
||||
|
||||
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 {
|
||||
|
@ -126,6 +164,7 @@ type RequestContext struct {
|
|||
CurrentUser *models.User
|
||||
CurrentSession *models.Session
|
||||
Theme string
|
||||
UrlContext *hmnurl.UrlContext
|
||||
|
||||
Perf *perf.RequestPerf
|
||||
|
||||
|
|
|
@ -8,6 +8,8 @@ import (
|
|||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
@ -154,14 +156,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
|||
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:
|
||||
hmnOnly.GET(hmnurl.RegexManifesto, Manifesto)
|
||||
hmnOnly.GET(hmnurl.RegexAbout, About)
|
||||
|
@ -175,14 +169,13 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
|||
|
||||
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.RegexLoginPage, LoginPage)
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexRegister, RegisterNewUser)
|
||||
hmnOnly.POST(hmnurl.RegexRegister, securityTimerMiddleware(email.ExpectedEmailSendDuration, RegisterNewUserSubmit))
|
||||
hmnOnly.GET(hmnurl.RegexRegistrationSuccess, RegisterNewUserSuccess)
|
||||
hmnOnly.GET(hmnurl.RegexOldEmailConfirmation, EmailConfirmation) // TODO(asaf): Delete this a bit after launch
|
||||
hmnOnly.GET(hmnurl.RegexEmailConfirmation, EmailConfirmation)
|
||||
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.RegexSnippet, Snippet)
|
||||
hmnOnly.GET(hmnurl.RegexProjectIndex, ProjectIndex)
|
||||
hmnOnly.GET(hmnurl.RegexProjectNotApproved, ProjectHomepage)
|
||||
|
||||
hmnOnly.GET(hmnurl.RegexDiscordOAuthCallback, authMiddleware(DiscordOAuthCallback))
|
||||
hmnOnly.POST(hmnurl.RegexDiscordUnlink, authMiddleware(csrfMiddleware(DiscordUnlink)))
|
||||
|
@ -224,38 +216,122 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
|||
|
||||
hmnOnly.GET(hmnurl.RegexLibraryAny, LibraryNotPortedYet)
|
||||
|
||||
// NOTE(asaf): Any-project routes:
|
||||
anyProject.GET(hmnurl.RegexForumNewThread, authMiddleware(ForumNewThread))
|
||||
anyProject.POST(hmnurl.RegexForumNewThreadSubmit, authMiddleware(csrfMiddleware(ForumNewThreadSubmit)))
|
||||
anyProject.GET(hmnurl.RegexForumThread, ForumThread)
|
||||
anyProject.GET(hmnurl.RegexForum, Forum)
|
||||
anyProject.POST(hmnurl.RegexForumMarkRead, authMiddleware(csrfMiddleware(ForumMarkRead)))
|
||||
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)
|
||||
attachProjectRoutes := func(rb *RouteBuilder) {
|
||||
rb.GET(hmnurl.RegexHomepage, func(c *RequestContext) ResponseData {
|
||||
if c.CurrentProject.IsHMN() {
|
||||
return Index(c)
|
||||
} else {
|
||||
return ProjectHomepage(c)
|
||||
}
|
||||
})
|
||||
|
||||
anyProject.GET(hmnurl.RegexBlog, BlogIndex)
|
||||
anyProject.GET(hmnurl.RegexBlogNewThread, authMiddleware(BlogNewThread))
|
||||
anyProject.POST(hmnurl.RegexBlogNewThread, authMiddleware(csrfMiddleware(BlogNewThreadSubmit)))
|
||||
anyProject.GET(hmnurl.RegexBlogThread, BlogThread)
|
||||
anyProject.GET(hmnurl.RegexBlogPost, BlogPostRedirectToThread)
|
||||
anyProject.GET(hmnurl.RegexBlogPostReply, authMiddleware(BlogPostReply))
|
||||
anyProject.POST(hmnurl.RegexBlogPostReply, authMiddleware(csrfMiddleware(BlogPostReplySubmit)))
|
||||
anyProject.GET(hmnurl.RegexBlogPostEdit, authMiddleware(BlogPostEdit))
|
||||
anyProject.POST(hmnurl.RegexBlogPostEdit, authMiddleware(csrfMiddleware(BlogPostEditSubmit)))
|
||||
anyProject.GET(hmnurl.RegexBlogPostDelete, authMiddleware(BlogPostDelete))
|
||||
anyProject.POST(hmnurl.RegexBlogPostDelete, authMiddleware(csrfMiddleware(BlogPostDeleteSubmit)))
|
||||
anyProject.GET(hmnurl.RegexBlogsRedirect, func(c *RequestContext) ResponseData {
|
||||
return c.Redirect(hmnurl.ProjectUrl(
|
||||
// Middleware used for forum action routes - anything related to actually creating or editing forum content
|
||||
needsForums := func(h Handler) Handler {
|
||||
return func(c *RequestContext) ResponseData {
|
||||
// 404 if the project has forums disabled
|
||||
if !c.CurrentProject.HasForums() {
|
||||
return FourOhFour(c)
|
||||
}
|
||||
// Require auth if forums are enabled
|
||||
return authMiddleware(h)(c)
|
||||
}
|
||||
}
|
||||
rb.POST(hmnurl.RegexForumNewThreadSubmit, needsForums(csrfMiddleware(ForumNewThreadSubmit)))
|
||||
rb.GET(hmnurl.RegexForumNewThread, needsForums(ForumNewThread))
|
||||
rb.GET(hmnurl.RegexForumThread, ForumThread)
|
||||
rb.GET(hmnurl.RegexForum, Forum)
|
||||
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,
|
||||
c.CurrentProject.Slug,
|
||||
), 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)
|
||||
|
||||
|
@ -277,31 +353,6 @@ func NewWebsiteRoutes(longRequestContext context.Context, conn *pgxpool.Pool, pe
|
|||
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 {
|
||||
color := c.URL().Query().Get("color")
|
||||
if color == "" {
|
||||
|
@ -382,22 +433,7 @@ func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
|
|||
c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
|
||||
defer c.Perf.EndBlock()
|
||||
|
||||
// get project
|
||||
{
|
||||
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
|
||||
}
|
||||
|
||||
// get user
|
||||
{
|
||||
sessionCookie, err := c.Req.Cookie(auth.SessionCookieName)
|
||||
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.
|
||||
}
|
||||
|
||||
theme := "light"
|
||||
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
||||
theme = "dark"
|
||||
// get official project
|
||||
{
|
||||
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"))
|
||||
}
|
||||
}
|
||||
|
||||
c.Theme = theme
|
||||
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 = "light"
|
||||
if c.CurrentUser != nil && c.CurrentUser.DarkTheme {
|
||||
c.Theme = "dark"
|
||||
}
|
||||
|
||||
return true, ResponseData{}
|
||||
}
|
||||
|
|
|
@ -3,9 +3,7 @@ package website
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/db"
|
||||
"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/templates"
|
||||
)
|
||||
|
@ -17,34 +15,14 @@ type ShowcaseData struct {
|
|||
}
|
||||
|
||||
func Showcase(c *RequestContext) ResponseData {
|
||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
||||
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
|
||||
`,
|
||||
)
|
||||
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{})
|
||||
if err != nil {
|
||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||
}
|
||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
||||
for _, s := range snippetQuerySlice {
|
||||
row := s.(*snippetQuery)
|
||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
||||
|
||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||
for _, s := range snippets {
|
||||
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
if timelineItem.CanShowcase {
|
||||
showcaseItems = append(showcaseItems, timelineItem)
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import (
|
|||
"strconv"
|
||||
|
||||
"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/templates"
|
||||
)
|
||||
|
@ -30,25 +29,7 @@ func Snippet(c *RequestContext) ResponseData {
|
|||
return FourOhFour(c)
|
||||
}
|
||||
|
||||
c.Perf.StartBlock("SQL", "Fetch snippet")
|
||||
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,
|
||||
)
|
||||
s, err := FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, SnippetQuery{})
|
||||
if err != nil {
|
||||
if errors.Is(err, db.NotFound) {
|
||||
return FourOhFour(c)
|
||||
|
@ -58,9 +39,7 @@ func Snippet(c *RequestContext) ResponseData {
|
|||
}
|
||||
c.Perf.EndBlock()
|
||||
|
||||
snippetData := snippetQueryResult.(*snippetQuery)
|
||||
|
||||
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
|
||||
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||
|
||||
opengraph := []templates.OpenGraphItem{
|
||||
{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
|
||||
NOT thread.deleted
|
||||
AND ( -- project has valid lifecycle
|
||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
||||
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||
OR project.id = $?
|
||||
)
|
||||
`,
|
||||
|
@ -219,7 +219,7 @@ func CountThreads(
|
|||
WHERE
|
||||
NOT thread.deleted
|
||||
AND ( -- project has valid lifecycle
|
||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
||||
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||
OR project.id = $?
|
||||
)
|
||||
`,
|
||||
|
@ -343,7 +343,7 @@ func FetchPosts(
|
|||
NOT thread.deleted
|
||||
AND NOT post.deleted
|
||||
AND ( -- project has valid lifecycle
|
||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
||||
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||
OR project.id = $?
|
||||
)
|
||||
`,
|
||||
|
@ -543,7 +543,7 @@ func CountPosts(
|
|||
NOT thread.deleted
|
||||
AND NOT post.deleted
|
||||
AND ( -- project has valid lifecycle
|
||||
project.flags = 0 AND project.lifecycle = ANY($?)
|
||||
NOT project.hidden AND project.lifecycle = ANY($?)
|
||||
OR project.id = $?
|
||||
)
|
||||
`,
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"fmt"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||
|
@ -24,18 +25,18 @@ var TimelineTypeTitleMap = map[models.ThreadType]TimelineTypeTitles{
|
|||
}
|
||||
|
||||
func PostToTimelineItem(
|
||||
urlContext *hmnurl.UrlContext,
|
||||
lineageBuilder *models.SubforumLineageBuilder,
|
||||
post *models.Post,
|
||||
thread *models.Thread,
|
||||
project *models.Project,
|
||||
owner *models.User,
|
||||
currentTheme string,
|
||||
) templates.TimelineItem {
|
||||
item := templates.TimelineItem{
|
||||
Date: post.PostDate,
|
||||
Title: thread.Title,
|
||||
Breadcrumbs: GenericThreadBreadcrumbs(lineageBuilder, project, thread),
|
||||
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
|
||||
Breadcrumbs: GenericThreadBreadcrumbs(urlContext, lineageBuilder, thread),
|
||||
Url: UrlForGenericPost(urlContext, thread, post, lineageBuilder),
|
||||
|
||||
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
|
||||
OwnerName: owner.BestName(),
|
||||
|
@ -63,6 +64,7 @@ func SnippetToTimelineItem(
|
|||
snippet *models.Snippet,
|
||||
asset *models.Asset,
|
||||
discordMessage *models.DiscordMessage,
|
||||
tags []*models.Tag,
|
||||
owner *models.User,
|
||||
currentTheme string,
|
||||
) templates.TimelineItem {
|
||||
|
@ -106,6 +108,13 @@ func SnippetToTimelineItem(
|
|||
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
|
||||
}
|
||||
|
||||
|
|
|
@ -108,7 +108,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
INNER JOIN handmade_user_projects AS uproj ON uproj.project_id = project.id
|
||||
WHERE
|
||||
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,
|
||||
(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))
|
||||
for _, projectRow := range projectQuerySlice {
|
||||
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()
|
||||
|
||||
|
@ -132,29 +136,12 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
})
|
||||
c.Perf.EndBlock()
|
||||
|
||||
type snippetQuery struct {
|
||||
Snippet models.Snippet `db:"snippet"`
|
||||
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,
|
||||
)
|
||||
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||
OwnerIDs: []int{profileUser.ID},
|
||||
})
|
||||
if err != nil {
|
||||
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")
|
||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||
|
@ -162,25 +149,25 @@ func UserProfile(c *RequestContext) ResponseData {
|
|||
c.Perf.EndBlock()
|
||||
|
||||
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 {
|
||||
timelineItems = append(timelineItems, PostToTimelineItem(
|
||||
UrlContextForProject(&post.Project),
|
||||
lineageBuilder,
|
||||
&post.Post,
|
||||
&post.Thread,
|
||||
&post.Project,
|
||||
profileUser,
|
||||
c.Theme,
|
||||
))
|
||||
}
|
||||
|
||||
for _, snippetRow := range snippetQuerySlice {
|
||||
snippetData := snippetRow.(*snippetQuery)
|
||||
for _, s := range snippets {
|
||||
item := SnippetToTimelineItem(
|
||||
&snippetData.Snippet,
|
||||
snippetData.Asset,
|
||||
snippetData.DiscordMessage,
|
||||
&s.Snippet,
|
||||
s.Asset,
|
||||
s.DiscordMessage,
|
||||
s.Tags,
|
||||
profileUser,
|
||||
c.Theme,
|
||||
)
|
||||
|
|
Loading…
Reference in New Issue