From a4671c5fb52710fc2518017d1a453aa9210270be Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Tue, 22 Jun 2021 12:50:40 +0300 Subject: [PATCH] Profile page and timeline items --- .gitignore | 1 + go.mod | 5 +- go.sum | 2 - public/icons.ttf | Bin 0 -> 5628 bytes public/style.css | 2 +- src/config/config.go.example | 9 + src/config/types.go | 23 +- src/db/db.go | 6 + src/hmnurl/hmnurl.go | 9 +- src/hmnurl/hmnurl_test.go | 8 +- src/hmnurl/urls.go | 23 +- src/models/asset.go | 18 ++ src/models/discord.go | 15 + src/models/link.go | 11 + src/models/snippet.go | 23 ++ src/rawdata/scss/_icons.scss | 2 +- src/templates/mapping.go | 90 +++++- src/templates/src/include/breadcrumbs.html | 6 + src/templates/src/include/header.html | 2 +- src/templates/src/user_profile.html | 126 +++++++++ src/templates/templates.go | 46 ++++ src/templates/types.go | 56 +++- src/website/post_helper.go | 54 ++-- src/website/routes.go | 9 +- src/website/timeline_helper.go | 170 ++++++++++++ src/website/user.go | 302 +++++++++++++++++++++ 26 files changed, 957 insertions(+), 61 deletions(-) create mode 100755 public/icons.ttf create mode 100644 src/models/asset.go create mode 100644 src/models/discord.go create mode 100644 src/models/link.go create mode 100644 src/models/snippet.go create mode 100644 src/templates/src/include/breadcrumbs.html create mode 100644 src/templates/src/user_profile.html create mode 100644 src/website/timeline_helper.go create mode 100644 src/website/user.go diff --git a/.gitignore b/.gitignore index 5f549c3a..e97999fb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ src/config/config.go vendor/ dbclones/ coverage.out +public/media/ diff --git a/go.mod b/go.mod index 374dbf46..b6165575 100644 --- a/go.mod +++ b/go.mod @@ -7,13 +7,12 @@ require ( github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig v2.22.0+incompatible github.com/go-stack/stack v1.8.0 - github.com/google/uuid v1.2.0 // indirect + github.com/google/uuid v1.2.0 github.com/huandu/xstrings v1.3.2 // indirect github.com/imdario/mergo v0.3.12 // indirect - github.com/jackc/pgconn v1.8.0 // indirect + github.com/jackc/pgconn v1.8.0 github.com/jackc/pgtype v1.6.2 github.com/jackc/pgx/v4 v4.10.1 - github.com/julienschmidt/httprouter v1.3.0 github.com/mitchellh/copystructure v1.1.1 // indirect github.com/rs/zerolog v1.20.0 github.com/spf13/cobra v1.1.3 diff --git a/go.sum b/go.sum index ab532624..fe48867a 100644 --- a/go.sum +++ b/go.sum @@ -170,8 +170,6 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= -github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= -github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= diff --git a/public/icons.ttf b/public/icons.ttf new file mode 100755 index 0000000000000000000000000000000000000000..99dfe74865d4bd568ef0b22419df91d9b7ca51d4 GIT binary patch literal 5628 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[^/]+)$`) +var RegexUserProfile = regexp.MustCompile(`^/m/(?P[^/]+)$`) -func BuildMember(username string) string { +func BuildUserProfile(username string) string { defer CatchPanic() if len(username) == 0 { panic(oops.New(nil, "Username must not be blank")) @@ -145,6 +145,17 @@ func BuildMember(username string) string { return Url("/m/"+username, nil) } +/* +* Snippets + */ + +var RegexSnippet = regexp.MustCompile(`^/snippet/(?P\d+)$`) + +func BuildSnippet(snippetId int) string { + defer CatchPanic() + return Url("/snippet/"+strconv.Itoa(snippetId), nil) +} + /* * Feed */ @@ -683,6 +694,12 @@ func BuildProjectCSS(color string) string { return Url("/assets/project.css", []Q{{"color", color}}) } +// NOTE(asaf): No Regex or tests for remote assets, since we don't parse it ourselves +func BuildS3Asset(s3key string) string { + defer CatchPanic() + return fmt.Sprintf("%s%s", S3BaseUrl, s3key) +} + var RegexPublic = regexp.MustCompile("^/public/.+$") func BuildPublic(filepath string, cachebust bool) string { diff --git a/src/models/asset.go b/src/models/asset.go new file mode 100644 index 00000000..f45fb05c --- /dev/null +++ b/src/models/asset.go @@ -0,0 +1,18 @@ +package models + +import ( + "github.com/google/uuid" +) + +type Asset struct { + ID uuid.UUID `db:"id"` + UploaderID *int `db:"uploader_id"` + + S3Key string `db:"s3_key"` + Filename string `db:"filename"` + Size int `db:"size"` + MimeType string `db:"mime_type"` + Sha1Sum string `db:"sha1sum"` + Width int `db:"width"` + Height int `db:"height"` +} diff --git a/src/models/discord.go b/src/models/discord.go new file mode 100644 index 00000000..52c0f4f6 --- /dev/null +++ b/src/models/discord.go @@ -0,0 +1,15 @@ +package models + +import ( + "time" +) + +type DiscordMessage struct { + ID string `db:"id"` + ChannelID string `db:"channel_id"` + GuildID *string `db:"guild_id"` + Url string `db:"url"` + UserID string `db:"user_id"` + SentAt time.Time `db:"sent_at"` + SnippetCreated bool `db:"snippet_created"` +} diff --git a/src/models/link.go b/src/models/link.go new file mode 100644 index 00000000..319339a5 --- /dev/null +++ b/src/models/link.go @@ -0,0 +1,11 @@ +package models + +type Link struct { + ID int `db:"id"` + Key string `db:"key"` + Name *string `db:"name"` + Value string `db:"value"` + Ordering int `db:"ordering"` + UserID *int `db:"user_id"` + ProjectID *int `db:"project_id"` +} diff --git a/src/models/snippet.go b/src/models/snippet.go new file mode 100644 index 00000000..3721e225 --- /dev/null +++ b/src/models/snippet.go @@ -0,0 +1,23 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type Snippet struct { + ID int `db:"id"` + OwnerID int `db:"owner_id"` + + When time.Time `db:"when"` + + Description string `db:"description"` + DescriptionHtml string `db:"_description_html"` + + Url *string `db:"url"` + AssetID *uuid.UUID `db:"asset_id"` + + EditedOnWebsite bool `db:"edited_on_website"` + DiscordMessageID *string `db:"discord_message_id"` +} diff --git a/src/rawdata/scss/_icons.scss b/src/rawdata/scss/_icons.scss index b77a4b3c..0c3307c4 100644 --- a/src/rawdata/scss/_icons.scss +++ b/src/rawdata/scss/_icons.scss @@ -1,6 +1,6 @@ @font-face { font-family: icons; - src: url("/static/icon/icons.ttf?v=4"); + src: url("/public/icons.ttf?v=4"); } span.icon { diff --git a/src/templates/mapping.go b/src/templates/mapping.go index 80244b50..f1bcf125 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -3,6 +3,7 @@ package templates import ( "html/template" "net" + "regexp" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -71,17 +72,22 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ models.ProjectLifecycleLTS: "Complete", } -func ProjectToTemplate(p *models.Project, theme string) Project { - logo := p.LogoLight - if theme == "dark" { - logo = p.LogoDark - } +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 { + logo := p.LogoLight + if theme == "dark" { + logo = p.LogoDark + } + url := ProjectUrl(p) return Project{ Name: p.Name, Subdomain: p.Subdomain(), @@ -115,36 +121,49 @@ func ThreadToTemplate(t *models.Thread) Thread { } } -func UserToTemplate(u *models.User, currentTheme string) User { - // TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function. +func UserAvatarUrl(u *models.User, currentTheme string) string { if currentTheme == "" { currentTheme = "light" } - avatar := "" if u.Avatar != nil && len(*u.Avatar) > 0 { avatar = hmnurl.BuildUserFile(*u.Avatar) } else { avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) } + return avatar +} +func UserDisplayName(u *models.User) string { name := u.Name if u.Name == "" { name = u.Username } + return name +} + +func UserToTemplate(u *models.User, currentTheme string) User { + // TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function. + + email := "" + if u.ShowEmail { + // TODO(asaf): Always show email to admins + email = u.Email + } return User{ ID: u.ID, Username: u.Username, - Email: u.Email, + Email: email, IsSuperuser: u.IsSuperuser, IsStaff: u.IsStaff, - Name: name, + Name: UserDisplayName(u), Blurb: u.Blurb, Signature: u.Signature, - AvatarUrl: avatar, - ProfileUrl: hmnurl.BuildMember(u.Username), + DateJoined: u.DateJoined, + AvatarUrl: UserAvatarUrl(u, currentTheme), + ProfileUrl: hmnurl.BuildUserProfile(u.Username), DarkTheme: u.DarkTheme, Timezone: u.Timezone, @@ -157,6 +176,53 @@ func UserToTemplate(u *models.User, currentTheme string) User { } } +var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P[\w/-]+)$`) +var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P\w+)$`) +var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P[\w/-]+)$`) +var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P[\w/-]+)$`) +var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P[\w/-]+)$`) +var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P[\w/-]+)$`) +var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P[\w/-]+)$`) +var RegexServiceItch = regexp.MustCompile(`(?P[\w/-]+)\.itch\.io/?$`) + +var LinkServiceMap = map[string]*regexp.Regexp{ + "youtube": RegexServiceYoutube, + "twitter": RegexServiceTwitter, + "github": RegexServiceGithub, + "twitch": RegexServiceTwitch, + "hitbox": RegexServiceHitbox, + "patreon": RegexServicePatreon, + "soundcloud": RegexServiceSoundcloud, + "itch": RegexServiceItch, +} + +func ParseKnownServicesForLink(link *models.Link) (serviceName string, userData string) { + for name, re := range LinkServiceMap { + match := re.FindStringSubmatch(link.Value) + if match != nil { + serviceName = name + userData = match[re.SubexpIndex("userdata")] + return + } + } + return "", "" +} + +func LinkToTemplate(link *models.Link) Link { + name := "" + if link.Name != nil { + name = *link.Name + } + serviceName, serviceUserData := ParseKnownServicesForLink(link) + return Link{ + Key: link.Key, + ServiceName: serviceName, + ServiceUserData: serviceUserData, + Name: name, + Value: link.Value, + } +} + func maybeString(s *string) string { if s == nil { return "" diff --git a/src/templates/src/include/breadcrumbs.html b/src/templates/src/include/breadcrumbs.html new file mode 100644 index 00000000..837f3489 --- /dev/null +++ b/src/templates/src/include/breadcrumbs.html @@ -0,0 +1,6 @@ + diff --git a/src/templates/src/include/header.html b/src/templates/src/include/header.html index c907acf3..90db2838 100644 --- a/src/templates/src/include/header.html +++ b/src/templates/src/include/header.html @@ -4,7 +4,7 @@ {{ if .User.IsSuperuser }} Admin {{ end }} - {{ .User.Username }} + {{ .User.Username }} Logout {{ else }} Register diff --git a/src/templates/src/user_profile.html b/src/templates/src/user_profile.html new file mode 100644 index 00000000..58c5653e --- /dev/null +++ b/src/templates/src/user_profile.html @@ -0,0 +1,126 @@ +{{ template "base.html" . }} + +{{ define "content" }} +
+
+ {{ with .ProfileUserProjects }} +
+

Projects

+
+ {{ range . }} +
+ {{ template "project_card.html" projectcarddata . "" }} +
+ {{ end }} +
+
+ {{ end }} + {{ if gt (len .TimelineItems) 0 }} +
+

Recent Activity

+
+ {{ if gt .NumForums 0 }} +
+ {{ end }} + {{ if gt .NumBlogs 0 }} +
+ {{ end }} + {{ if gt .NumWiki 0 }} +
+ {{ end }} + {{ if gt .NumLibrary 0 }} +
+ {{ end }} + {{ if gt .NumSnippets 0 }} +
+ {{ end }} +
+
+ {{ range .TimelineItems }} + {{ if timelinepostitem . }} +
+ +
+ {{ template "breadcrumbs.html" .Breadcrumbs }} +
{{ .TypeTitle }}: {{ .Title }}
+
+ {{ .OwnerName }} — {{ timehtml (relativedate .Date) .Date }} +
+
+
+ {{ else if timelinesnippetitem . }} +
+ +

{{ .Description }}

+
+ {{ if snippetvideo . }} +
+
+ {{ end }} + {{ end }} +
+
+ {{ end }} +
+ +
+ +{{ end }} diff --git a/src/templates/templates.go b/src/templates/templates.go index aad8cb92..450dba27 100644 --- a/src/templates/templates.go +++ b/src/templates/templates.go @@ -72,9 +72,18 @@ func names(ts []*template.Template) []string { } var HMNTemplateFuncs = template.FuncMap{ + "add": func(a int, b ...int) int { + for _, num := range b { + a += num + } + return a + }, "absolutedate": func(t time.Time) string { return t.UTC().Format("January 2, 2006, 3:04pm") }, + "absoluteshortdate": func(t time.Time) string { + return t.UTC().Format("January 2, 2006") + }, "rfc3339": func(t time.Time) string { return t.UTC().Format(time.RFC3339) }, @@ -166,6 +175,43 @@ var HMNTemplateFuncs = template.FuncMap{ Classes: classes, } }, + + "timelinepostitem": func(item TimelineItem) bool { + if item.Type == TimelineTypeForumThread || + item.Type == TimelineTypeForumReply || + item.Type == TimelineTypeBlogPost || + item.Type == TimelineTypeBlogComment || + item.Type == TimelineTypeWikiCreate || + item.Type == TimelineTypeWikiEdit || + item.Type == TimelineTypeWikiTalk || + item.Type == TimelineTypeLibraryComment { + + return true + } + return false + }, + "timelinesnippetitem": func(item TimelineItem) bool { + if item.Type == TimelineTypeSnippetImage || + item.Type == TimelineTypeSnippetVideo || + item.Type == TimelineTypeSnippetAudio || + item.Type == TimelineTypeSnippetYoutube { + + return true + } + return false + }, + "snippetvideo": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetVideo + }, + "snippetaudio": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetAudio + }, + "snippetimage": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetImage + }, + "snippetyoutube": func(snippet TimelineItem) bool { + return snippet.Type == TimelineTypeSnippetYoutube + }, } type ErrInvalidHexColor struct { diff --git a/src/templates/types.go b/src/templates/types.go index 0ee81792..6f26b91a 100644 --- a/src/templates/types.go +++ b/src/templates/types.go @@ -27,7 +27,7 @@ type BaseData struct { type Header struct { AdminUrl string - MemberSettingsUrl string + UserSettingsUrl string LoginActionUrl string LogoutActionUrl string RegisterUrl string @@ -124,6 +124,7 @@ type User struct { Blurb string Bio string Signature string + DateJoined time.Time AvatarUrl string ProfileUrl string @@ -137,6 +138,14 @@ type User struct { DiscordDeleteSnippetOnMessageDelete bool } +type Link struct { + Key string + ServiceName string + ServiceUserData string + Name string + Value string +} + type OpenGraphItem struct { Property string Name string @@ -197,6 +206,51 @@ type ThreadListItem struct { Content string } +type TimelineType int + +const ( + TimelineTypeUnknown TimelineType = iota + + TimelineTypeForumThread + TimelineTypeForumReply + + TimelineTypeBlogPost + TimelineTypeBlogComment + + TimelineTypeWikiCreate + TimelineTypeWikiEdit + TimelineTypeWikiTalk + + TimelineTypeLibraryComment + + TimelineTypeSnippetImage + TimelineTypeSnippetVideo + TimelineTypeSnippetAudio + TimelineTypeSnippetYoutube +) + +type TimelineItem struct { + Type TimelineType + TypeTitle string + Class string + Date time.Time + Url string + + OwnerAvatarUrl string + OwnerName string + OwnerUrl string + Description template.HTML + + DiscordMessageUrl string + Width int + Height int + AssetUrl string + YoutubeID string + + Title string + Breadcrumbs []Breadcrumb +} + type ProjectCardData struct { Project *Project Classes string diff --git a/src/website/post_helper.go b/src/website/post_helper.go index 3f9fc194..55bdbf82 100644 --- a/src/website/post_helper.go +++ b/src/website/post_helper.go @@ -47,6 +47,35 @@ var PostTypePrefix = map[templates.PostType]string{ templates.PostTypeLibraryComment: "Library comment", } +func PostBreadcrumbs(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, post *models.Post, libraryResource *models.LibraryResource) []templates.Breadcrumb { + var result []templates.Breadcrumb + result = append(result, templates.Breadcrumb{ + Name: project.Name, + Url: hmnurl.BuildProjectHomepage(project.Slug), + }) + result = append(result, templates.Breadcrumb{ + Name: CategoryKindDisplayNames[post.CategoryKind], + Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), + }) + switch post.CategoryKind { + case models.CatKindForum: + subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) + slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) + for i, subforum := range subforums { + result = append(result, templates.Breadcrumb{ + Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. + Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), + }) + } + case models.CatKindLibraryResource: + result = append(result, templates.Breadcrumb{ + Name: libraryResource.Name, + Url: hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID), + }) + } + return result +} + // NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts. func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem { var result templates.PostListItem @@ -75,30 +104,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo result.PostTypePrefix = PostTypePrefix[result.PostType] if includeBreadcrumbs { - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: project.Name, - Url: hmnurl.BuildProjectHomepage(project.Slug), - }) - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: CategoryKindDisplayNames[post.CategoryKind], - Url: BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), - }) - switch post.CategoryKind { - case models.CatKindForum: - subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) - slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) - for i, subforum := range subforums { - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names. - Url: hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), - }) - } - case models.CatKindLibraryResource: - result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ - Name: libraryResource.Name, - Url: hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID), - }) - } + result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource) } return result diff --git a/src/website/routes.go b/src/website/routes.go index 90652cce..a5030ab0 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -103,16 +103,20 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy) staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines) + // TODO(asaf): Have separate middleware for HMN-only routes and any-project routes + // NOTE(asaf): HMN-only routes: mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) - mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) + mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) + // NOTE(asaf): Any-project routes: mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) + // Other mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour) return router @@ -139,7 +143,7 @@ func getBaseData(c *RequestContext) templates.BaseData { IsProjectPage: !c.CurrentProject.IsHMN(), Header: templates.Header{ AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf) - MemberSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) + UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()), LogoutActionUrl: hmnurl.BuildLogoutAction(), RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf) @@ -256,6 +260,7 @@ func FourOhFour(c *RequestContext) ResponseData { func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { c.Perf.StartBlock("MIDDLEWARE", "Load common website data") defer c.Perf.EndBlock() + // get project { slug := "" diff --git a/src/website/timeline_helper.go b/src/website/timeline_helper.go new file mode 100644 index 00000000..8a7c69bb --- /dev/null +++ b/src/website/timeline_helper.go @@ -0,0 +1,170 @@ +package website + +import ( + "html/template" + "regexp" + "strings" + + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/templates" +) + +var TimelineTypeMap = map[models.CategoryKind][]templates.TimelineType{ + // No parent, Has parent + models.CatKindBlog: []templates.TimelineType{templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, + models.CatKindForum: []templates.TimelineType{templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, + models.CatKindWiki: []templates.TimelineType{templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk}, + models.CatKindLibraryResource: []templates.TimelineType{templates.TimelineTypeLibraryComment, templates.TimelineTypeLibraryComment}, +} + +var TimelineItemClassMap = map[templates.TimelineType]string{ + templates.TimelineTypeUnknown: "", + + templates.TimelineTypeForumThread: "forums", + templates.TimelineTypeForumReply: "forums", + + templates.TimelineTypeBlogPost: "blogs", + templates.TimelineTypeBlogComment: "blogs", + + templates.TimelineTypeWikiCreate: "wiki", + templates.TimelineTypeWikiEdit: "wiki", + templates.TimelineTypeWikiTalk: "wiki", + + templates.TimelineTypeLibraryComment: "library", + + templates.TimelineTypeSnippetImage: "snippets", + templates.TimelineTypeSnippetVideo: "snippets", + templates.TimelineTypeSnippetAudio: "snippets", + templates.TimelineTypeSnippetYoutube: "snippets", +} + +var TimelineTypeTitleMap = map[templates.TimelineType]string{ + templates.TimelineTypeUnknown: "", + + templates.TimelineTypeForumThread: "New forums thread", + templates.TimelineTypeForumReply: "Forum reply", + + templates.TimelineTypeBlogPost: "New blog post", + templates.TimelineTypeBlogComment: "Blog comment", + + templates.TimelineTypeWikiCreate: "New wiki article", + templates.TimelineTypeWikiEdit: "Wiki edit", + templates.TimelineTypeWikiTalk: "Wiki talk", + + templates.TimelineTypeLibraryComment: "Library comment", + + templates.TimelineTypeSnippetImage: "Snippet", + templates.TimelineTypeSnippetVideo: "Snippet", + templates.TimelineTypeSnippetAudio: "Snippet", + templates.TimelineTypeSnippetYoutube: "Snippet", +} + +func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, libraryResource *models.LibraryResource, owner *models.User, currentTheme string) templates.TimelineItem { + itemType := templates.TimelineTypeUnknown + typeByCatKind, found := TimelineTypeMap[post.CategoryKind] + if found { + hasParent := 0 + if post.ParentID != nil { + hasParent = 1 + } + itemType = typeByCatKind[hasParent] + } + + libraryResourceId := 0 + if libraryResource != nil { + libraryResourceId = libraryResource.ID + } + + return templates.TimelineItem{ + Type: itemType, + TypeTitle: TimelineTypeTitleMap[itemType], + Class: TimelineItemClassMap[itemType], + Date: post.PostDate, + Url: UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug), + + OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), + OwnerName: templates.UserDisplayName(owner), + OwnerUrl: hmnurl.BuildUserProfile(owner.Username), + Description: "", // NOTE(asaf): No description for posts + + Title: thread.Title, + Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, libraryResource), + } +} + +func PostVersionToWikiTimelineItem(lineageBuilder *models.CategoryLineageBuilder, version *models.PostVersion, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem { + return templates.TimelineItem{ + Type: templates.TimelineTypeWikiEdit, + TypeTitle: TimelineTypeTitleMap[templates.TimelineTypeWikiEdit], + Class: TimelineItemClassMap[templates.TimelineTypeWikiEdit], + Date: version.EditDate, + Url: hmnurl.BuildWikiArticle(project.Slug, thread.ID, thread.Title), + + OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), + OwnerName: templates.UserDisplayName(owner), + OwnerUrl: hmnurl.BuildUserProfile(owner.Username), + Description: "", // NOTE(asaf): No description for posts + + Title: thread.Title, + Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, nil), + } +} + +var YoutubeRegex = regexp.MustCompile(`(?i)youtube\.com/watch\?.*v=(?P[^/&]+)`) +var YoutubeShortRegex = regexp.MustCompile(`(?i)youtu\.be/(?P[^/]+)`) + +func SnippetToTimelineItem(snippet *models.Snippet, asset *models.Asset, discordMessage *models.DiscordMessage, owner *models.User, currentTheme string) templates.TimelineItem { + itemType := templates.TimelineTypeUnknown + youtubeId := "" + assetUrl := "" + width := 0 + height := 0 + discordMessageUrl := "" + + if asset == nil { + match := YoutubeRegex.FindStringSubmatch(*snippet.Url) + index := YoutubeRegex.SubexpIndex("videoid") + if match == nil { + match = YoutubeShortRegex.FindStringSubmatch(*snippet.Url) + index = YoutubeShortRegex.SubexpIndex("videoid") + } + if match != nil { + youtubeId = match[index] + itemType = templates.TimelineTypeSnippetYoutube + } + } else { + if strings.HasPrefix(asset.MimeType, "image/") { + itemType = templates.TimelineTypeSnippetImage + } else if strings.HasPrefix(asset.MimeType, "video/") { + itemType = templates.TimelineTypeSnippetVideo + } else if strings.HasPrefix(asset.MimeType, "audio/") { + itemType = templates.TimelineTypeSnippetAudio + } + assetUrl = hmnurl.BuildS3Asset(asset.S3Key) + width = asset.Width + height = asset.Height + } + + if discordMessage != nil { + discordMessageUrl = discordMessage.Url + } + + return templates.TimelineItem{ + Type: itemType, + Class: TimelineItemClassMap[itemType], + Date: snippet.When, + Url: hmnurl.BuildSnippet(snippet.ID), + + OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), + OwnerName: templates.UserDisplayName(owner), + OwnerUrl: hmnurl.BuildUserProfile(owner.Username), + Description: template.HTML(snippet.DescriptionHtml), + + DiscordMessageUrl: discordMessageUrl, + Width: width, + Height: height, + AssetUrl: assetUrl, + YoutubeID: youtubeId, + } +} diff --git a/src/website/user.go b/src/website/user.go new file mode 100644 index 00000000..6fb5f036 --- /dev/null +++ b/src/website/user.go @@ -0,0 +1,302 @@ +package website + +import ( + "errors" + "net/http" + "sort" + "strings" + + "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" +) + +type UserProfileTemplateData struct { + templates.BaseData + ProfileUser templates.User + ProfileUserLinks []templates.Link + ProfileUserProjects []templates.Project + TimelineItems []templates.TimelineItem + NumForums int + NumBlogs int + NumWiki int + NumLibrary int + NumSnippets int +} + +func UserProfile(c *RequestContext) ResponseData { + username, hasUsername := c.PathParams["username"] + + if !hasUsername || len(strings.TrimSpace(username)) == 0 { + return FourOhFour(c) + } + + username = strings.ToLower(username) + + var profileUser *models.User + if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username { + profileUser = c.CurrentUser + } else { + c.Perf.StartBlock("SQL", "Fetch user") + userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{}, + ` + SELECT $columns + FROM + auth_user + WHERE + LOWER(auth_user.username) = $1 + `, + username, + ) + c.Perf.EndBlock() + if err != nil { + if errors.Is(err, db.ErrNoMatchingRows) { + return FourOhFour(c) + } else { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username)) + } + } + profileUser = userResult.(*models.User) + } + c.Perf.StartBlock("SQL", "Fetch user links") + type userLinkQuery struct { + UserLink models.Link `db:"link"` + } + userLinkQueryResult, err := db.Query(c.Context(), c.Conn, userLinkQuery{}, + ` + SELECT $columns + FROM + handmade_links as link + WHERE + link.user_id = $1 + ORDER BY link.ordering ASC + `, + profileUser.ID, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username)) + } + userLinksSlice := userLinkQueryResult.ToSlice() + profileUserLinks := make([]templates.Link, 0, len(userLinksSlice)) + for _, l := range userLinksSlice { + profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink)) + } + c.Perf.EndBlock() + + type projectQuery struct { + Project models.Project `db:"project"` + } + c.Perf.StartBlock("SQL", "Fetch projects") + projectQueryResult, err := db.Query(c.Context(), c.Conn, projectQuery{}, + ` + SELECT $columns + FROM + handmade_project AS project + INNER JOIN handmade_project_groups AS project_groups ON project_groups.project_id = project.id + INNER JOIN auth_user_groups AS user_groups ON user_groups.group_id = project_groups.group_id + WHERE + user_groups.user_id = $1 + AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3))) + `, + profileUser.ID, + (c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsSuperuser)), + models.VisibleProjectLifecycles, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for user: %s", username)) + } + projectQuerySlice := projectQueryResult.ToSlice() + templateProjects := make([]templates.Project, 0, len(projectQuerySlice)) + for _, projectRow := range projectQuerySlice { + projectData := projectRow.(*projectQuery) + templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme)) + } + c.Perf.EndBlock() + + type postQuery struct { + Post models.Post `db:"post"` + Thread models.Thread `db:"thread"` + LibraryResource *models.LibraryResource `db:"lib_resource"` + Project models.Project `db:"project"` + } + c.Perf.StartBlock("SQL", "Fetch posts") + postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{}, + ` + SELECT $columns + FROM + handmade_post AS post + INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id + INNER JOIN handmade_project AS project ON project.id = post.project_id + LEFT JOIN handmade_libraryresource AS lib_resource ON lib_resource.category_id = post.category_id + WHERE + post.author_id = $1 + AND project.lifecycle = ANY ($2) + `, + profileUser.ID, + models.VisibleProjectLifecycles, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch posts for user: %s", username)) + } + postQuerySlice := postQueryResult.ToSlice() + c.Perf.EndBlock() + + type wikiEditQuery struct { + PostVersion models.PostVersion `db:"version"` + Post models.Post `db:"post"` + Thread models.Thread `db:"thread"` + Project models.Project `db:"project"` + } + c.Perf.StartBlock("SQL", "Fetch wiki edits") + wikiEditQueryResult, err := db.Query(c.Context(), c.Conn, wikiEditQuery{}, + ` + SELECT $columns + FROM + handmade_postversion AS version + INNER JOIN handmade_post AS post ON post.id = version.post_id + INNER JOIN handmade_thread AS thread on thread.id = post.thread_id + INNER JOIN handmade_project AS project ON project.id = post.project_id + WHERE + version.editor_id = $1 + AND post.parent_id IS NULL + AND post.category_kind = $2 + AND project.lifecycle = ANY ($3) + `, + profileUser.ID, + models.CatKindWiki, + models.VisibleProjectLifecycles, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch wiki edits for user: %s", username)) + } + wikiEditQuerySlice := wikiEditQueryResult.ToSlice() + 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, + ) + if err != nil { + return 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 category tree") + categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn) + lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree) + c.Perf.EndBlock() + + c.Perf.StartBlock("PROFILE", "Construct timeline items") + timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(wikiEditQuerySlice)+len(snippetQuerySlice)) + numForums := 0 + numBlogs := 0 + numWiki := len(wikiEditQuerySlice) + numLibrary := 0 + numSnippets := len(snippetQuerySlice) + + for _, postRow := range postQuerySlice { + postData := postRow.(*postQuery) + timelineItem := PostToTimelineItem( + lineageBuilder, + &postData.Post, + &postData.Thread, + &postData.Project, + postData.LibraryResource, + profileUser, + c.Theme, + ) + switch timelineItem.Type { + case templates.TimelineTypeForumThread: + numForums += 1 + case templates.TimelineTypeForumReply: + numForums += 1 + + case templates.TimelineTypeBlogPost: + numBlogs += 1 + case templates.TimelineTypeBlogComment: + numBlogs += 1 + + case templates.TimelineTypeWikiCreate: + numWiki += 1 + case templates.TimelineTypeWikiTalk: + numWiki += 1 + + case templates.TimelineTypeLibraryComment: + numLibrary += 1 + } + if timelineItem.Type != templates.TimelineTypeUnknown { + timelineItems = append(timelineItems, timelineItem) + } else { + c.Logger.Warn().Int("post ID", postData.Post.ID).Msg("Unknown timeline item type for post") + } + } + + for _, wikiEditRow := range wikiEditQuerySlice { + wikiEditData := wikiEditRow.(*wikiEditQuery) + timelineItem := PostVersionToWikiTimelineItem( + lineageBuilder, + &wikiEditData.PostVersion, + &wikiEditData.Post, + &wikiEditData.Thread, + &wikiEditData.Project, + profileUser, + c.Theme, + ) + timelineItems = append(timelineItems, timelineItem) + } + + for _, snippetRow := range snippetQuerySlice { + snippetData := snippetRow.(*snippetQuery) + timelineItem := SnippetToTimelineItem( + &snippetData.Snippet, + snippetData.Asset, + snippetData.DiscordMessage, + profileUser, + c.Theme, + ) + timelineItems = append(timelineItems, timelineItem) + } + + c.Perf.StartBlock("PROFILE", "Sort timeline") + sort.Slice(timelineItems, func(i, j int) bool { + return timelineItems[j].Date.Before(timelineItems[i].Date) + }) + c.Perf.EndBlock() + + c.Perf.EndBlock() + + baseData := getBaseData(c) + var res ResponseData + err = res.WriteTemplate("user_profile.html", UserProfileTemplateData{ + BaseData: baseData, + ProfileUser: templates.UserToTemplate(profileUser, c.Theme), + ProfileUserLinks: profileUserLinks, + ProfileUserProjects: templateProjects, + TimelineItems: timelineItems, + NumForums: numForums, + NumBlogs: numBlogs, + NumWiki: numWiki, + NumLibrary: numLibrary, + NumSnippets: numSnippets, + }, c.Perf) + if err != nil { + panic(err) + } + return res +}