Add tags to snippets on timelines
This commit is contained in:
parent
ffed86b33a
commit
4ea1338c32
|
@ -64,7 +64,12 @@ func (bot *botInstance) processShowcaseMsg(ctx context.Context, msg *Message, is
|
||||||
}
|
}
|
||||||
|
|
||||||
if isJam {
|
if isJam {
|
||||||
_, err := tx.Exec(ctx, `UPDATE handmade_snippet SET is_jam = TRUE WHERE id = $1`, snippet.ID)
|
tagId, err := db.QueryInt(ctx, tx, `SELECT id FROM tags WHERE text = 'wheeljam'`)
|
||||||
|
if err != nil {
|
||||||
|
return oops.New(err, "failed to fetch id of jam tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = tx.Exec(ctx, `INSERT INTO snippet_tags (snippet_id, tag_id) VALUES ($1, $2)`, snippet.ID, tagId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return oops.New(err, "failed to mark snippet as a jam snippet")
|
return oops.New(err, "failed to mark snippet as a jam snippet")
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/migration/types"
|
"git.handmade.network/hmn/hmn/src/migration/types"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"github.com/jackc/pgx/v4"
|
"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,
|
id SERIAL NOT NULL PRIMARY KEY,
|
||||||
text VARCHAR(20) NOT NULL
|
text VARCHAR(20) NOT NULL
|
||||||
);
|
);
|
||||||
|
CREATE INDEX tags_by_text ON tags (text);
|
||||||
|
|
||||||
ALTER TABLE tags
|
ALTER TABLE tags
|
||||||
ADD CONSTRAINT tag_syntax CHECK (
|
ADD CONSTRAINT tag_syntax CHECK (
|
||||||
text ~ '^([a-z0-9]+(-[a-z0-9]+)*)?$'
|
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 {
|
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, `
|
_, 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")
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
var err 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, `
|
_, err = tx.Exec(ctx, `
|
||||||
ALTER TABLE handmade_project
|
ALTER TABLE handmade_project
|
||||||
DROP CONSTRAINT slug_syntax,
|
DROP CONSTRAINT slug_syntax,
|
||||||
|
@ -115,6 +184,17 @@ func (m PersonalProjects) Down(ctx context.Context, tx pgx.Tx) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = tx.Exec(ctx, `
|
_, 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;
|
DROP TABLE tags;
|
||||||
`)
|
`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
@ -43,7 +43,7 @@ type Project struct {
|
||||||
|
|
||||||
Slug string `db:"slug"`
|
Slug string `db:"slug"`
|
||||||
Name string `db:"name"`
|
Name string `db:"name"`
|
||||||
Tag string `db:"tag"`
|
TagID *int `db:"tag"`
|
||||||
Blurb string `db:"blurb"`
|
Blurb string `db:"blurb"`
|
||||||
Description string `db:"description"`
|
Description string `db:"description"`
|
||||||
ParsedDescription string `db:"descparsed"`
|
ParsedDescription string `db:"descparsed"`
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
package models
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
ID int `db:"id"`
|
||||||
|
Text string `db:"text"`
|
||||||
|
}
|
|
@ -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 {
|
func maybeString(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|
|
@ -59,4 +59,14 @@
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
||||||
|
{{ with .Tags }}
|
||||||
|
<div class="mt3 flex">
|
||||||
|
{{ range $i, $tag := . }}
|
||||||
|
<div class="bg-theme-dimmer ph2 pv1 br2 {{ if gt $i 0 }}ml2{{ end }}">
|
||||||
|
{{ $tag.Text }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -268,6 +268,7 @@ type TimelineItem struct {
|
||||||
OwnerName string
|
OwnerName string
|
||||||
OwnerUrl string
|
OwnerUrl string
|
||||||
|
|
||||||
|
Tags []Tag
|
||||||
Description template.HTML
|
Description template.HTML
|
||||||
|
|
||||||
PreviewMedia TimelineItemMedia
|
PreviewMedia TimelineItemMedia
|
||||||
|
@ -330,3 +331,8 @@ type DiscordUser struct {
|
||||||
Discriminator string
|
Discriminator string
|
||||||
Avatar string
|
Avatar string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Tag struct {
|
||||||
|
Text string
|
||||||
|
Url string
|
||||||
|
}
|
||||||
|
|
|
@ -14,6 +14,11 @@ func getBaseDataAutocrumb(c *RequestContext, title string) templates.BaseData {
|
||||||
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
|
// NOTE(asaf): If you set breadcrumbs, the breadcrumb for the current project will automatically be prepended when necessary.
|
||||||
// If you pass nil, no breadcrumbs will be created.
|
// If you pass nil, no breadcrumbs will be created.
|
||||||
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
|
func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadcrumb) templates.BaseData {
|
||||||
|
var project models.Project
|
||||||
|
if c.CurrentProject != nil {
|
||||||
|
project = *c.CurrentProject
|
||||||
|
}
|
||||||
|
|
||||||
var templateUser *templates.User
|
var templateUser *templates.User
|
||||||
var templateSession *templates.Session
|
var templateSession *templates.Session
|
||||||
if c.CurrentUser != nil {
|
if c.CurrentUser != nil {
|
||||||
|
@ -29,7 +34,7 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
projectUrl := c.UrlContext.BuildHomepage()
|
projectUrl := c.UrlContext.BuildHomepage()
|
||||||
if breadcrumbs[0].Url != projectUrl {
|
if breadcrumbs[0].Url != projectUrl {
|
||||||
rootBreadcrumb := templates.Breadcrumb{
|
rootBreadcrumb := templates.Breadcrumb{
|
||||||
Name: c.CurrentProject.Name,
|
Name: project.Name,
|
||||||
Url: projectUrl,
|
Url: projectUrl,
|
||||||
}
|
}
|
||||||
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
|
breadcrumbs = append([]templates.Breadcrumb{rootBreadcrumb}, breadcrumbs...)
|
||||||
|
@ -44,18 +49,18 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
CurrentUrl: c.FullUrl(),
|
CurrentUrl: c.FullUrl(),
|
||||||
CurrentProjectUrl: c.UrlContext.BuildHomepage(),
|
CurrentProjectUrl: c.UrlContext.BuildHomepage(),
|
||||||
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
LoginPageUrl: hmnurl.BuildLoginPage(c.FullUrl()),
|
||||||
ProjectCSSUrl: hmnurl.BuildProjectCSS(c.CurrentProject.Color1),
|
ProjectCSSUrl: hmnurl.BuildProjectCSS(project.Color1),
|
||||||
|
|
||||||
Project: templates.ProjectToTemplate(c.CurrentProject, c.UrlContext.BuildHomepage(), c.Theme),
|
Project: templates.ProjectToTemplate(&project, c.UrlContext.BuildHomepage(), c.Theme),
|
||||||
User: templateUser,
|
User: templateUser,
|
||||||
Session: templateSession,
|
Session: templateSession,
|
||||||
Notices: notices,
|
Notices: notices,
|
||||||
|
|
||||||
ReportIssueMailto: "team@handmade.network",
|
ReportIssueMailto: "team@handmade.network",
|
||||||
|
|
||||||
OpenGraphItems: buildDefaultOpenGraphItems(c.CurrentProject, title),
|
OpenGraphItems: buildDefaultOpenGraphItems(&project, title),
|
||||||
|
|
||||||
IsProjectPage: !c.CurrentProject.IsHMN(),
|
IsProjectPage: !project.IsHMN(),
|
||||||
Header: templates.Header{
|
Header: templates.Header{
|
||||||
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
|
AdminUrl: hmnurl.BuildAdminApprovalQueue(), // TODO(asaf): Replace with general-purpose admin page
|
||||||
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
UserSettingsUrl: hmnurl.BuildUserSettings(""),
|
||||||
|
@ -86,16 +91,16 @@ func getBaseData(c *RequestContext, title string, breadcrumbs []templates.Breadc
|
||||||
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
|
baseData.Header.UserProfileUrl = hmnurl.BuildUserProfile(c.CurrentUser.Username)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !c.CurrentProject.IsHMN() {
|
if !project.IsHMN() {
|
||||||
episodeGuideUrl := ""
|
episodeGuideUrl := ""
|
||||||
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[c.CurrentProject.Slug]
|
defaultTopic, hasAnnotations := config.Config.EpisodeGuide.Projects[project.Slug]
|
||||||
if hasAnnotations {
|
if hasAnnotations {
|
||||||
episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic)
|
episodeGuideUrl = c.UrlContext.BuildEpisodeList(defaultTopic)
|
||||||
}
|
}
|
||||||
|
|
||||||
baseData.Header.Project = &templates.ProjectHeader{
|
baseData.Header.Project = &templates.ProjectHeader{
|
||||||
HasForums: c.CurrentProject.HasForums(),
|
HasForums: project.HasForums(),
|
||||||
HasBlog: c.CurrentProject.HasBlog(),
|
HasBlog: project.HasBlog(),
|
||||||
HasEpisodeGuide: hasAnnotations,
|
HasEpisodeGuide: hasAnnotations,
|
||||||
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
|
ForumsUrl: c.UrlContext.BuildForum(nil, 1),
|
||||||
BlogUrl: c.UrlContext.BuildBlog(1),
|
BlogUrl: c.UrlContext.BuildBlog(1),
|
||||||
|
|
|
@ -228,35 +228,14 @@ func AtomFeed(c *RequestContext) ResponseData {
|
||||||
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
|
feedData.AtomFeedUrl = hmnurl.BuildAtomFeedForShowcase()
|
||||||
feedData.FeedUrl = hmnurl.BuildShowcase()
|
feedData.FeedUrl = hmnurl.BuildShowcase()
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
type snippetQuery struct {
|
Limit: itemsPerFeed,
|
||||||
Owner models.User `db:"owner"`
|
})
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
NOT snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
LIMIT $1
|
|
||||||
`,
|
|
||||||
itemsPerFeed,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
for _, s := range snippets {
|
||||||
for _, s := range snippetQuerySlice {
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
row := s.(*snippetQuery)
|
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
feedData.Snippets = append(feedData.Snippets, timelineItem)
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
|
@ -4,9 +4,7 @@ import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
@ -20,35 +18,23 @@ func JamIndex(c *RequestContext) ResponseData {
|
||||||
daysUntil = 0
|
daysUntil = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
var tagIds []int
|
||||||
type snippetQuery struct {
|
jamTag, err := FetchTag(c.Context(), c.Conn, "wheeljam")
|
||||||
Owner models.User `db:"owner"`
|
if err == nil {
|
||||||
Snippet models.Snippet `db:"snippet"`
|
tagIds = []int{jamTag.ID}
|
||||||
Asset *models.Asset `db:"asset"`
|
} else {
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
c.Logger.Warn().Err(err).Msg("failed to fetch jam tag; will fetch all snippets as a result")
|
||||||
}
|
}
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
SELECT $columns
|
Tags: tagIds,
|
||||||
FROM
|
})
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
LIMIT 20
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch jam snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
for _, s := range snippets {
|
||||||
for _, s := range snippetQuerySlice {
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
row := s.(*snippetQuery)
|
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
if timelineItem.CanShowcase {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"math"
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
"git.handmade.network/hmn/hmn/src/models"
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
|
@ -106,35 +105,15 @@ func Index(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
type snippetQuery struct {
|
Limit: 40,
|
||||||
Owner models.User `db:"owner"`
|
})
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
NOT snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
LIMIT 40
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
for _, s := range snippets {
|
||||||
for _, s := range snippetQuerySlice {
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
row := s.(*snippetQuery)
|
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
if timelineItem.CanShowcase {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,9 +3,7 @@ package website
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
@ -17,34 +15,14 @@ type ShowcaseData struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func Showcase(c *RequestContext) ResponseData {
|
func Showcase(c *RequestContext) ResponseData {
|
||||||
c.Perf.StartBlock("SQL", "Fetch showcase snippets")
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{})
|
||||||
type snippetQuery struct {
|
|
||||||
Owner models.User `db:"owner"`
|
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
NOT snippet.is_jam
|
|
||||||
ORDER BY snippet.when DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets"))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
|
||||||
showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice))
|
showcaseItems := make([]templates.TimelineItem, 0, len(snippets))
|
||||||
for _, s := range snippetQuerySlice {
|
for _, s := range snippets {
|
||||||
row := s.(*snippetQuery)
|
timelineItem := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
|
|
||||||
if timelineItem.CanShowcase {
|
if timelineItem.CanShowcase {
|
||||||
showcaseItems = append(showcaseItems, timelineItem)
|
showcaseItems = append(showcaseItems, timelineItem)
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,6 @@ import (
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/db"
|
"git.handmade.network/hmn/hmn/src/db"
|
||||||
"git.handmade.network/hmn/hmn/src/models"
|
|
||||||
"git.handmade.network/hmn/hmn/src/oops"
|
"git.handmade.network/hmn/hmn/src/oops"
|
||||||
"git.handmade.network/hmn/hmn/src/templates"
|
"git.handmade.network/hmn/hmn/src/templates"
|
||||||
)
|
)
|
||||||
|
@ -30,25 +29,7 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch snippet")
|
s, err := FetchSnippet(c.Context(), c.Conn, c.CurrentUser, snippetId, SnippetQuery{})
|
||||||
type snippetQuery struct {
|
|
||||||
Owner models.User `db:"owner"`
|
|
||||||
Snippet models.Snippet `db:"snippet"`
|
|
||||||
Asset *models.Asset `db:"asset"`
|
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
snippetQueryResult, err := db.QueryOne(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE snippet.id = $1
|
|
||||||
`,
|
|
||||||
snippetId,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, db.NotFound) {
|
if errors.Is(err, db.NotFound) {
|
||||||
return FourOhFour(c)
|
return FourOhFour(c)
|
||||||
|
@ -58,9 +39,7 @@ func Snippet(c *RequestContext) ResponseData {
|
||||||
}
|
}
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
snippetData := snippetQueryResult.(*snippetQuery)
|
snippet := SnippetToTimelineItem(&s.Snippet, s.Asset, s.DiscordMessage, s.Tags, s.Owner, c.Theme)
|
||||||
|
|
||||||
snippet := SnippetToTimelineItem(&snippetData.Snippet, snippetData.Asset, snippetData.DiscordMessage, &snippetData.Owner, c.Theme)
|
|
||||||
|
|
||||||
opengraph := []templates.OpenGraphItem{
|
opengraph := []templates.OpenGraphItem{
|
||||||
{Property: "og:site_name", Value: "Handmade.Network"},
|
{Property: "og:site_name", Value: "Handmade.Network"},
|
||||||
|
|
|
@ -0,0 +1,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
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.handmade.network/hmn/hmn/src/hmnurl"
|
"git.handmade.network/hmn/hmn/src/hmnurl"
|
||||||
|
@ -63,6 +64,7 @@ func SnippetToTimelineItem(
|
||||||
snippet *models.Snippet,
|
snippet *models.Snippet,
|
||||||
asset *models.Asset,
|
asset *models.Asset,
|
||||||
discordMessage *models.DiscordMessage,
|
discordMessage *models.DiscordMessage,
|
||||||
|
tags []*models.Tag,
|
||||||
owner *models.User,
|
owner *models.User,
|
||||||
currentTheme string,
|
currentTheme string,
|
||||||
) templates.TimelineItem {
|
) templates.TimelineItem {
|
||||||
|
@ -106,6 +108,13 @@ func SnippetToTimelineItem(
|
||||||
item.DiscordMessageUrl = discordMessage.Url
|
item.DiscordMessageUrl = discordMessage.Url
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sort.Slice(tags, func(i, j int) bool {
|
||||||
|
return tags[i].Text < tags[j].Text
|
||||||
|
})
|
||||||
|
for _, tag := range tags {
|
||||||
|
item.Tags = append(item.Tags, templates.TagToTemplate(tag))
|
||||||
|
}
|
||||||
|
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -136,29 +136,12 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
})
|
})
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
type snippetQuery struct {
|
snippets, err := FetchSnippets(c.Context(), c.Conn, c.CurrentUser, SnippetQuery{
|
||||||
Snippet models.Snippet `db:"snippet"`
|
OwnerIDs: []int{profileUser.ID},
|
||||||
Asset *models.Asset `db:"asset"`
|
})
|
||||||
DiscordMessage *models.DiscordMessage `db:"discord_message"`
|
|
||||||
}
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch snippets")
|
|
||||||
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
|
|
||||||
`
|
|
||||||
SELECT $columns
|
|
||||||
FROM
|
|
||||||
handmade_snippet AS snippet
|
|
||||||
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
|
|
||||||
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
|
|
||||||
WHERE
|
|
||||||
snippet.owner_id = $1
|
|
||||||
`,
|
|
||||||
profileUser.ID,
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
|
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
|
||||||
}
|
}
|
||||||
snippetQuerySlice := snippetQueryResult.ToSlice()
|
|
||||||
c.Perf.EndBlock()
|
|
||||||
|
|
||||||
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
c.Perf.StartBlock("SQL", "Fetch subforum tree")
|
||||||
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
|
||||||
|
@ -166,7 +149,7 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
c.Perf.EndBlock()
|
c.Perf.EndBlock()
|
||||||
|
|
||||||
c.Perf.StartBlock("PROFILE", "Construct timeline items")
|
c.Perf.StartBlock("PROFILE", "Construct timeline items")
|
||||||
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippetQuerySlice))
|
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippets))
|
||||||
|
|
||||||
for _, post := range posts {
|
for _, post := range posts {
|
||||||
timelineItems = append(timelineItems, PostToTimelineItem(
|
timelineItems = append(timelineItems, PostToTimelineItem(
|
||||||
|
@ -179,12 +162,12 @@ func UserProfile(c *RequestContext) ResponseData {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, snippetRow := range snippetQuerySlice {
|
for _, s := range snippets {
|
||||||
snippetData := snippetRow.(*snippetQuery)
|
|
||||||
item := SnippetToTimelineItem(
|
item := SnippetToTimelineItem(
|
||||||
&snippetData.Snippet,
|
&s.Snippet,
|
||||||
snippetData.Asset,
|
s.Asset,
|
||||||
snippetData.DiscordMessage,
|
s.DiscordMessage,
|
||||||
|
s.Tags,
|
||||||
profileUser,
|
profileUser,
|
||||||
c.Theme,
|
c.Theme,
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in New Issue