diff --git a/src/discord/showcase.go b/src/discord/showcase.go
index 185c0e3e..7ca9f3ea 100644
--- a/src/discord/showcase.go
+++ b/src/discord/showcase.go
@@ -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")
}
diff --git a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go
index 4f5d3cf1..552319b0 100644
--- a/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go
+++ b/src/migration/migrations/2021-11-06T033930Z_PersonalProjects.go
@@ -4,6 +4,8 @@ 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"
@@ -35,16 +37,31 @@ func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
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 INDEX tags_by_text ON tags (text);
+ 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 table")
+ 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, `
@@ -89,12 +106,64 @@ func (m PersonalProjects) Up(ctx context.Context, tx pgx.Tx) error {
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,
@@ -115,6 +184,17 @@ func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
}
_, 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 {
diff --git a/src/models/project.go b/src/models/project.go
index 24dc591f..62824928 100644
--- a/src/models/project.go
+++ b/src/models/project.go
@@ -43,7 +43,7 @@ type Project struct {
Slug string `db:"slug"`
Name string `db:"name"`
- Tag string `db:"tag"`
+ TagID *int `db:"tag"`
Blurb string `db:"blurb"`
Description string `db:"description"`
ParsedDescription string `db:"descparsed"`
diff --git a/src/models/tag.go b/src/models/tag.go
new file mode 100644
index 00000000..81871c3d
--- /dev/null
+++ b/src/models/tag.go
@@ -0,0 +1,6 @@
+package models
+
+type Tag struct {
+ ID int `db:"id"`
+ Text string `db:"text"`
+}
diff --git a/src/templates/mapping.go b/src/templates/mapping.go
index 5bf86ac6..30cd2db7 100644
--- a/src/templates/mapping.go
+++ b/src/templates/mapping.go
@@ -368,6 +368,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 ""
diff --git a/src/templates/src/include/timeline_item.html b/src/templates/src/include/timeline_item.html
index 62f40491..285fb6b9 100644
--- a/src/templates/src/include/timeline_item.html
+++ b/src/templates/src/include/timeline_item.html
@@ -59,4 +59,14 @@
{{ end }}
{{ end }}
+
+ {{ with .Tags }}
+
+ {{ range $i, $tag := . }}
+
+ {{ $tag.Text }}
+
+ {{ end }}
+
+ {{ end }}
diff --git a/src/templates/types.go b/src/templates/types.go
index 14c25aed..5c86f581 100644
--- a/src/templates/types.go
+++ b/src/templates/types.go
@@ -268,6 +268,7 @@ type TimelineItem struct {
OwnerName string
OwnerUrl string
+ Tags []Tag
Description template.HTML
PreviewMedia TimelineItemMedia
@@ -330,3 +331,8 @@ type DiscordUser struct {
Discriminator string
Avatar string
}
+
+type Tag struct {
+ Text string
+ Url string
+}
diff --git a/src/website/base_data.go b/src/website/base_data.go
index 350b66aa..fa4bc574 100644
--- a/src/website/base_data.go
+++ b/src/website/base_data.go
@@ -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 {
@@ -29,7 +34,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
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...)
@@ -44,18 +49,18 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
CurrentUrl: c.FullUrl(),
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.UrlContext.BuildHomepage(), 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(""),
@@ -86,16 +91,16 @@ 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 = c.UrlContext.BuildEpisodeList(defaultTopic)
}
baseData.Header.Project = &templates.ProjectHeader{
- HasForums: c.CurrentProject.HasForums(),
- HasBlog: c.CurrentProject.HasBlog(),
+ HasForums: project.HasForums(),
+ HasBlog: project.HasBlog(),
HasEpisodeGuide: hasAnnotations,
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
BlogUrl: c.UrlContext.BuildBlog(1),
diff --git a/src/website/feed.go b/src/website/feed.go
index 5250d478..4baa3ea1 100644
--- a/src/website/feed.go
+++ b/src/website/feed.go
@@ -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()
diff --git a/src/website/jam.go b/src/website/jam.go
index 9789d04e..aadb3051 100644
--- a/src/website/jam.go
+++ b/src/website/jam.go
@@ -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,23 @@ 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"`
+ var tagIds []int
+ jamTag, err := FetchTag(c.Context(), c.Conn, "wheeljam")
+ if err == nil {
+ tagIds = []int{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: tagIds,
+ })
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)
}
diff --git a/src/website/landing.go b/src/website/landing.go
index 188655d6..6803e302 100644
--- a/src/website/landing.go
+++ b/src/website/landing.go
@@ -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"
@@ -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)
}
diff --git a/src/website/showcase.go b/src/website/showcase.go
index 1750c90d..18d5eceb 100644
--- a/src/website/showcase.go
+++ b/src/website/showcase.go
@@ -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)
}
diff --git a/src/website/snippet.go b/src/website/snippet.go
index f41fb0c0..bdeca517 100644
--- a/src/website/snippet.go
+++ b/src/website/snippet.go
@@ -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"},
diff --git a/src/website/snippet_helper.go b/src/website/snippet_helper.go
new file mode 100644
index 00000000..50d748ab
--- /dev/null
+++ b/src/website/snippet_helper.go
@@ -0,0 +1,192 @@
+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
+}
+
+func FetchTags(ctx context.Context, dbConn db.ConnOrTx, text []string) ([]*models.Tag, error) {
+ perf := ExtractPerf(ctx)
+ perf.StartBlock("SQL", "Fetch snippets")
+ defer perf.EndBlock()
+
+ it, err := db.Query(ctx, dbConn, models.Tag{},
+ `
+ SELECT $columns
+ FROM tags
+ WHERE text = ANY ($1)
+ `,
+ text,
+ )
+ 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, text string) (*models.Tag, error) {
+ tags, err := FetchTags(ctx, dbConn, []string{text})
+ if err != nil {
+ return nil, err
+ }
+ if len(tags) == 0 {
+ return nil, db.NotFound
+ }
+ return tags[0], nil
+}
diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go
index ef3ca3c1..e24cb058 100644
--- a/src/website/timeline_helper.go
+++ b/src/website/timeline_helper.go
@@ -4,6 +4,7 @@ import (
"fmt"
"html/template"
"regexp"
+ "sort"
"strings"
"git.handmade.network/hmn/hmn/src/hmnurl"
@@ -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
}
diff --git a/src/website/user.go b/src/website/user.go
index 597aa3b3..fa00eccc 100644
--- a/src/website/user.go
+++ b/src/website/user.go
@@ -136,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)
@@ -166,7 +149,7 @@ 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(
@@ -179,12 +162,12 @@ func UserProfile(c *RequestContext) ResponseData {
))
}
- 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,
)