Profile page and timeline items

This commit is contained in:
Asaf Gartner 2021-06-22 12:50:40 +03:00
parent b6c611004c
commit a4671c5fb5
26 changed files with 957 additions and 61 deletions

1
.gitignore vendored
View File

@ -3,3 +3,4 @@ src/config/config.go
vendor/ vendor/
dbclones/ dbclones/
coverage.out coverage.out
public/media/

5
go.mod
View File

@ -7,13 +7,12 @@ require (
github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/sprig v2.22.0+incompatible github.com/Masterminds/sprig v2.22.0+incompatible
github.com/go-stack/stack v1.8.0 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/huandu/xstrings v1.3.2 // indirect
github.com/imdario/mergo v0.3.12 // 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/pgtype v1.6.2
github.com/jackc/pgx/v4 v4.10.1 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/mitchellh/copystructure v1.1.1 // indirect
github.com/rs/zerolog v1.20.0 github.com/rs/zerolog v1.20.0
github.com/spf13/cobra v1.1.3 github.com/spf13/cobra v1.1.3

2
go.sum
View File

@ -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/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/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.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/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 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= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=

BIN
public/icons.ttf Executable file

Binary file not shown.

View File

@ -8947,7 +8947,7 @@ div.mark_as_read_toplevel_blog {
@font-face { @font-face {
font-family: icons; font-family: icons;
src: url("/static/icon/icons.ttf?v=4"); } src: url("/public/icons.ttf?v=4"); }
span.icon { span.icon {
font-family: "icons"; } font-family: "icons"; }

View File

@ -18,4 +18,13 @@ var Config = HMNConfig{
CookieDomain: ".handmade.local", CookieDomain: ".handmade.local",
CookieSecure: false, CookieSecure: false,
}, },
DigitalOcean: DigitalOceanConfig{
AssetsSpacesKey: "",
AssetsSpacesSecret: "",
AssetsSpacesRegion: "",
AssetsSpacesEndpoint: "",
AssetsSpacesBucket: "",
AssetsPathPrefix: "",
AssetsPublicUrlRoot: "",
},
} }

View File

@ -22,6 +22,7 @@ type HMNConfig struct {
LogLevel zerolog.Level LogLevel zerolog.Level
Postgres PostgresConfig Postgres PostgresConfig
Auth AuthConfig Auth AuthConfig
DigitalOcean DigitalOceanConfig
} }
type PostgresConfig struct { type PostgresConfig struct {
@ -40,6 +41,16 @@ type AuthConfig struct {
CookieSecure bool CookieSecure bool
} }
type DigitalOceanConfig struct {
AssetsSpacesKey string
AssetsSpacesSecret string
AssetsSpacesRegion string
AssetsSpacesEndpoint string
AssetsSpacesBucket string
AssetsPathPrefix string
AssetsPublicUrlRoot string
}
func (info PostgresConfig) DSN() string { func (info PostgresConfig) DSN() string {
return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName) return fmt.Sprintf("user=%s password=%s host=%s port=%d dbname=%s", info.User, info.Password, info.Hostname, info.Port, info.DbName)
} }

View File

@ -10,6 +10,7 @@ import (
"git.handmade.network/hmn/hmn/src/config" "git.handmade.network/hmn/hmn/src/config"
"git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"github.com/google/uuid"
"github.com/jackc/pgtype" "github.com/jackc/pgtype"
"github.com/jackc/pgx/v4" "github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/log/zerologadapter" "github.com/jackc/pgx/v4/log/zerologadapter"
@ -38,6 +39,8 @@ func typeIsQueryable(t reflect.Type) bool {
if isRecognizedByPgtype { if isRecognizedByPgtype {
return true return true
} else if t == reflect.TypeOf(uuid.UUID{}) {
return true
} }
// pgtype doesn't recognize it, but maybe it's a primitive type we can deal with // pgtype doesn't recognize it, but maybe it's a primitive type we can deal with
@ -100,10 +103,12 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
// Better logging of panics in this confusing reflection process // Better logging of panics in this confusing reflection process
var currentField reflect.StructField var currentField reflect.StructField
var currentValue reflect.Value var currentValue reflect.Value
var currentIdx int
defer func() { defer func() {
if r := recover(); r != nil { if r := recover(); r != nil {
if currentValue.IsValid() { if currentValue.IsValid() {
logging.Error(). logging.Error().
Int("index", currentIdx).
Str("field name", currentField.Name). Str("field name", currentField.Name).
Stringer("field type", currentField.Type). Stringer("field type", currentField.Type).
Interface("value", currentValue.Interface()). Interface("value", currentValue.Interface()).
@ -120,6 +125,7 @@ func (it *StructQueryIterator) Next() (interface{}, bool) {
}() }()
for i, val := range vals { for i, val := range vals {
currentIdx = i
if val == nil { if val == nil {
continue continue
} }

View File

@ -10,9 +10,6 @@ import (
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
) )
const StaticPath = "/public"
const StaticThemePath = "/public/themes"
type Q struct { type Q struct {
Name string Name string
Value string Value string
@ -30,11 +27,13 @@ func QFromURL(u *url.URL) []Q {
var baseUrlParsed url.URL var baseUrlParsed url.URL
var cacheBust string var cacheBust string
var S3BaseUrl string
var isTest bool var isTest bool
func init() { func init() {
SetGlobalBaseUrl(config.Config.BaseUrl) SetGlobalBaseUrl(config.Config.BaseUrl)
SetCacheBust(fmt.Sprint(time.Now().Unix())) SetCacheBust(fmt.Sprint(time.Now().Unix()))
SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot)
} }
func SetGlobalBaseUrl(fullBaseUrl string) { func SetGlobalBaseUrl(fullBaseUrl string) {
@ -53,6 +52,10 @@ func SetCacheBust(newCacheBust string) {
cacheBust = newCacheBust cacheBust = newCacheBust
} }
func SetS3BaseUrl(base string) {
S3BaseUrl = base
}
func Url(path string, query []Q) string { func Url(path string, query []Q) string {
return ProjectUrl(path, query, "") return ProjectUrl(path, query, "")
} }

View File

@ -80,8 +80,12 @@ func TestStaticPages(t *testing.T) {
AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil) AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil)
} }
func TestMember(t *testing.T) { func TestUserProfile(t *testing.T) {
AssertRegexMatch(t, BuildMember("test"), RegexMember, map[string]string{"member": "test"}) AssertRegexMatch(t, BuildUserProfile("test"), RegexUserProfile, map[string]string{"username": "test"})
}
func TestSnippet(t *testing.T) {
AssetRegexMatch(t, BuildSnippet(15), RegexSnippet, map[string]string{"snippetid": "15"})
} }
func TestFeed(t *testing.T) { func TestFeed(t *testing.T) {

View File

@ -132,12 +132,12 @@ func BuildProjectSubmissionGuidelines() string {
} }
/* /*
* Member * User
*/ */
var RegexMember = regexp.MustCompile(`^/m/(?P<member>[^/]+)$`) var RegexUserProfile = regexp.MustCompile(`^/m/(?P<username>[^/]+)$`)
func BuildMember(username string) string { func BuildUserProfile(username string) string {
defer CatchPanic() defer CatchPanic()
if len(username) == 0 { if len(username) == 0 {
panic(oops.New(nil, "Username must not be blank")) panic(oops.New(nil, "Username must not be blank"))
@ -145,6 +145,17 @@ func BuildMember(username string) string {
return Url("/m/"+username, nil) return Url("/m/"+username, nil)
} }
/*
* Snippets
*/
var RegexSnippet = regexp.MustCompile(`^/snippet/(?P<snippetid>\d+)$`)
func BuildSnippet(snippetId int) string {
defer CatchPanic()
return Url("/snippet/"+strconv.Itoa(snippetId), nil)
}
/* /*
* Feed * Feed
*/ */
@ -683,6 +694,12 @@ func BuildProjectCSS(color string) string {
return Url("/assets/project.css", []Q{{"color", color}}) 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/.+$") var RegexPublic = regexp.MustCompile("^/public/.+$")
func BuildPublic(filepath string, cachebust bool) string { func BuildPublic(filepath string, cachebust bool) string {

18
src/models/asset.go Normal file
View File

@ -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"`
}

15
src/models/discord.go Normal file
View File

@ -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"`
}

11
src/models/link.go Normal file
View File

@ -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"`
}

23
src/models/snippet.go Normal file
View File

@ -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"`
}

View File

@ -1,6 +1,6 @@
@font-face { @font-face {
font-family: icons; font-family: icons;
src: url("/static/icon/icons.ttf?v=4"); src: url("/public/icons.ttf?v=4");
} }
span.icon { span.icon {

View File

@ -3,6 +3,7 @@ package templates
import ( import (
"html/template" "html/template"
"net" "net"
"regexp"
"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"
@ -71,17 +72,22 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{
models.ProjectLifecycleLTS: "Complete", models.ProjectLifecycleLTS: "Complete",
} }
func ProjectToTemplate(p *models.Project, theme string) Project { func ProjectUrl(p *models.Project) string {
logo := p.LogoLight
if theme == "dark" {
logo = p.LogoDark
}
var url string var url string
if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired {
url = hmnurl.BuildProjectNotApproved(p.Slug) url = hmnurl.BuildProjectNotApproved(p.Slug)
} else { } else {
url = hmnurl.BuildProjectHomepage(p.Slug) 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{ return Project{
Name: p.Name, Name: p.Name,
Subdomain: p.Subdomain(), Subdomain: p.Subdomain(),
@ -115,36 +121,49 @@ func ThreadToTemplate(t *models.Thread) Thread {
} }
} }
func UserToTemplate(u *models.User, currentTheme string) User { func UserAvatarUrl(u *models.User, currentTheme string) string {
// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function.
if currentTheme == "" { if currentTheme == "" {
currentTheme = "light" currentTheme = "light"
} }
avatar := "" avatar := ""
if u.Avatar != nil && len(*u.Avatar) > 0 { if u.Avatar != nil && len(*u.Avatar) > 0 {
avatar = hmnurl.BuildUserFile(*u.Avatar) avatar = hmnurl.BuildUserFile(*u.Avatar)
} else { } else {
avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true)
} }
return avatar
}
func UserDisplayName(u *models.User) string {
name := u.Name name := u.Name
if u.Name == "" { if u.Name == "" {
name = u.Username 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{ return User{
ID: u.ID, ID: u.ID,
Username: u.Username, Username: u.Username,
Email: u.Email, Email: email,
IsSuperuser: u.IsSuperuser, IsSuperuser: u.IsSuperuser,
IsStaff: u.IsStaff, IsStaff: u.IsStaff,
Name: name, Name: UserDisplayName(u),
Blurb: u.Blurb, Blurb: u.Blurb,
Signature: u.Signature, Signature: u.Signature,
AvatarUrl: avatar, DateJoined: u.DateJoined,
ProfileUrl: hmnurl.BuildMember(u.Username), AvatarUrl: UserAvatarUrl(u, currentTheme),
ProfileUrl: hmnurl.BuildUserProfile(u.Username),
DarkTheme: u.DarkTheme, DarkTheme: u.DarkTheme,
Timezone: u.Timezone, Timezone: u.Timezone,
@ -157,6 +176,53 @@ func UserToTemplate(u *models.User, currentTheme string) User {
} }
} }
var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P<userdata>[\w/-]+)$`)
var RegexServiceTwitter = regexp.MustCompile(`twitter\.com/(?P<userdata>\w+)$`)
var RegexServiceGithub = regexp.MustCompile(`github\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceTwitch = regexp.MustCompile(`twitch\.tv/(?P<userdata>[\w/-]+)$`)
var RegexServiceHitbox = regexp.MustCompile(`hitbox\.tv/(?P<userdata>[\w/-]+)$`)
var RegexServicePatreon = regexp.MustCompile(`patreon\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceSoundcloud = regexp.MustCompile(`soundcloud\.com/(?P<userdata>[\w/-]+)$`)
var RegexServiceItch = regexp.MustCompile(`(?P<userdata>[\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 { func maybeString(s *string) string {
if s == nil { if s == nil {
return "" return ""

View File

@ -0,0 +1,6 @@
<div class="breadcrumbs f7 o-80">
{{ range $i, $breadcrumb := . }}
{{ if gt $i 0 }} » {{ end }}
<a href="{{ $breadcrumb.Url }}">{{ $breadcrumb.Name }}</a>
{{ end }}
</div>

View File

@ -4,7 +4,7 @@
{{ if .User.IsSuperuser }} {{ if .User.IsSuperuser }}
<a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a> <a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a>
{{ end }} {{ end }}
<a class="username settings" href="{{ .Header.MemberSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a> <a class="username settings" href="{{ .Header.UserSettingsUrl }}"><span class="icon-settings"></span> {{ .User.Username }}</a>
<a class="logout" href="{{ .Header.LogoutActionUrl }}"><span class="icon-logout"></span> Logout</a> <a class="logout" href="{{ .Header.LogoutActionUrl }}"><span class="icon-logout"></span> Logout</a>
{{ else }} {{ else }}
<a class="register" id="register-link" href="{{ .Header.RegisterUrl }}">Register</a> <a class="register" id="register-link" href="{{ .Header.RegisterUrl }}">Register</a>

View File

@ -0,0 +1,126 @@
{{ template "base.html" . }}
{{ define "content" }}
<div class="flex flex-column flex-row-l">
<div class="flex-grow-1 overflow-hidden">
{{ with .ProfileUserProjects }}
<div class="content-block ph3 ph0-ns">
<h2>Projects</h2>
<div class="ph3">
{{ range . }}
<div class="mv3">
{{ template "project_card.html" projectcarddata . "" }}
</div>
{{ end }}
</div>
</div>
{{ end }}
{{ if gt (len .TimelineItems) 0 }}
<div class="content-block timeline-container ph3 ph0-ns">
<h2>Recent Activity</h2>
<div class="timeline-filters mb2">
{{ if gt .NumForums 0 }}
<div class="dib filter forums mr2"><input data-type="forums" class="v-mid mr1" type="checkbox" id="timeline-checkbox-forums" checked /><label class="v-mid" for="timeline-checkbox-forums">Forums (<span class="count">{{ .NumForums }}</span>)</label></div>
{{ end }}
{{ if gt .NumBlogs 0 }}
<div class="dib filter blogs mr2"><input data-type="blogs" class="v-mid mr1" type="checkbox" id="timeline-checkbox-blogs" checked /><label class="v-mid" for="timeline-checkbox-blogs">Blogs (<span class="count">{{ .NumBlogs }}</span>)</label></div>
{{ end }}
{{ if gt .NumWiki 0 }}
<div class="dib filter wiki mr2"><input data-type="wiki" class="v-mid mr1" type="checkbox" id="timeline-checkbox-wiki" checked /><label class="v-mid" for="timeline-checkbox-wiki">Wiki (<span class="count">{{ .NumWiki }}</span>)</label></div>
{{ end }}
{{ if gt .NumLibrary 0 }}
<div class="dib filter library mr2"><input data-type="library" class="v-mid mr1" type="checkbox" id="timeline-checkbox-library" checked /><label class="v-mid" for="timeline-checkbox-library">Library (<span class="count">{{ .NumLibrary }}</span>)</label></div>
{{ end }}
{{ if gt .NumSnippets 0 }}
<div class="dib filter snippets mr2"><input data-type="snippets" class="v-mid mr1" type="checkbox" id="timeline-checkbox-snippets" checked /><label class="v-mid" for="timeline-checkbox-snippets">Snippets (<span class="count">{{ .NumSnippets }}</span>)</label></div>
{{ end }}
</div>
<div class="timeline">
{{ range .TimelineItems }}
{{ if timelinepostitem . }}
<div class="timeline-item flex pa3 mb2 br3 {{ .Class }}">
<img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/>
<div class="timeline-info overflow-hidden">
{{ template "breadcrumbs.html" .Breadcrumbs }}
<div class="title f5 b nowrap truncate"><span>{{ .TypeTitle }}</span>: <a href="{{ .Url }}" class="title-text normal">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
</div>
</div>
{{ else if timelinesnippetitem . }}
<div class="timeline-item flex flex-column pa3 mb2 br3 {{ .Class }}">
<div class="timeline-user-info mb2 flex items-center">
<img class="avatar-icon lite mr2" src="{{ .OwnerAvatarUrl }}"/>
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
<a class="datetime tr" style="flex: 1 1 auto;" href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
</div>
<p class="timeline-snippet-title mb2">{{ .Description }}</p>
<div class="timeline-content-box {{ if snippetyoutube . }}youtube{{ end }}">
{{ if snippetvideo . }}
<video src="{{ .AssetUrl }}" preload="metadata" controls />
{{ else if snippetimage . }}
<img src="{{ .AssetUrl }}" />
{{ else if snippetaudio . }}
<audio src="{{ .AssetUrl }}" controls />
{{ else if snippetyoutube .}}
<iframe src="https://www.youtube-nocookie.com/embed/{{ .YoutubeID }}" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0"></iframe>
{{ end }}
</div>
</div>
{{ end }}
{{ end }}
</div>
</div>
{{ end }}
</div>
<div class="sidebar flex-shrink-0 mw6 w-30-l self-center self-start-l mh3 mh0-ns ml3-l overflow-hidden">
<div class="content-block box avatar">
<img alt="{{ .ProfileUser.Name }}'s Avatar" src="{{ .ProfileUser.AvatarUrl }}" />
</div>
<div class="content-block box list">
<div class="content-block">
<div class="description">
<p>{{ .ProfileUser.Bio }}</p>
</div>
</div>
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Member since</div>
<div class="value">{{ absoluteshortdate .ProfileUser.DateJoined }}</div>
</div>
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Posts</div>
<div class="value">{{ add .NumForums .NumBlogs .NumWiki .NumLibrary }}</div>
</div>
{{ if .ProfileUser.Email }}
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Email</div>
<div class="value">{{ .ProfileUser.Email }}</div>
</div>
{{ end }}
{{ range .ProfileUserLinks }}
<div class="pair flex flex-wra[">
<div class="key flex-auto mr1">{{ .Key }}</div>
<div class="value projectlink"><a class="external" href="{{ .Value }}" ><span class="icon-{{ .ServiceName }}"></span> {{ if .ServiceUserData }}{{ .ServiceUserData }}{{ else }}{{ .Value }}{{ end }}</a></div>
</div>
{{ end }}
</div>
</div>
</div>
<script>
let timelineList = document.querySelector(".timeline");
let toggles = document.querySelectorAll(".timeline-filters .filter input");
function timelineFilterToggle(ev) {
timelineList.classList.toggle("no-" + ev.target.getAttribute("data-type"), !ev.target.checked);
}
for (let i = 0; i < toggles.length; ++i) {
let toggle = toggles[i];
toggle.checked = true;
toggle.addEventListener("change", timelineFilterToggle);
}
</script>
{{ end }}

View File

@ -72,9 +72,18 @@ func names(ts []*template.Template) []string {
} }
var HMNTemplateFuncs = template.FuncMap{ 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 { "absolutedate": func(t time.Time) string {
return t.UTC().Format("January 2, 2006, 3:04pm") 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 { "rfc3339": func(t time.Time) string {
return t.UTC().Format(time.RFC3339) return t.UTC().Format(time.RFC3339)
}, },
@ -166,6 +175,43 @@ var HMNTemplateFuncs = template.FuncMap{
Classes: classes, 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 { type ErrInvalidHexColor struct {

View File

@ -27,7 +27,7 @@ type BaseData struct {
type Header struct { type Header struct {
AdminUrl string AdminUrl string
MemberSettingsUrl string UserSettingsUrl string
LoginActionUrl string LoginActionUrl string
LogoutActionUrl string LogoutActionUrl string
RegisterUrl string RegisterUrl string
@ -124,6 +124,7 @@ type User struct {
Blurb string Blurb string
Bio string Bio string
Signature string Signature string
DateJoined time.Time
AvatarUrl string AvatarUrl string
ProfileUrl string ProfileUrl string
@ -137,6 +138,14 @@ type User struct {
DiscordDeleteSnippetOnMessageDelete bool DiscordDeleteSnippetOnMessageDelete bool
} }
type Link struct {
Key string
ServiceName string
ServiceUserData string
Name string
Value string
}
type OpenGraphItem struct { type OpenGraphItem struct {
Property string Property string
Name string Name string
@ -197,6 +206,51 @@ type ThreadListItem struct {
Content string 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 { type ProjectCardData struct {
Project *Project Project *Project
Classes string Classes string

View File

@ -47,6 +47,35 @@ var PostTypePrefix = map[templates.PostType]string{
templates.PostTypeLibraryComment: "Library comment", 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. // 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 { 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 var result templates.PostListItem
@ -75,30 +104,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo
result.PostTypePrefix = PostTypePrefix[result.PostType] result.PostTypePrefix = PostTypePrefix[result.PostType]
if includeBreadcrumbs { if includeBreadcrumbs {
result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource)
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),
})
}
} }
return result return result

View File

@ -103,16 +103,20 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy) staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy)
staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines) 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.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex)
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)
// NOTE(asaf): Any-project routes:
mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) mainRoutes.GET(hmnurl.RegexForumThread, ForumThread)
mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory)
mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS)
// Other
mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour) mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour)
return router return router
@ -139,7 +143,7 @@ func getBaseData(c *RequestContext) templates.BaseData {
IsProjectPage: !c.CurrentProject.IsHMN(), IsProjectPage: !c.CurrentProject.IsHMN(),
Header: templates.Header{ Header: templates.Header{
AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf) AdminUrl: hmnurl.BuildHomepage(), // TODO(asaf)
MemberSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf) UserSettingsUrl: hmnurl.BuildHomepage(), // TODO(asaf)
LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()), LoginActionUrl: hmnurl.BuildLoginAction(c.FullUrl()),
LogoutActionUrl: hmnurl.BuildLogoutAction(), LogoutActionUrl: hmnurl.BuildLogoutAction(),
RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf) RegisterUrl: hmnurl.BuildHomepage(), // TODO(asaf)
@ -256,6 +260,7 @@ func FourOhFour(c *RequestContext) ResponseData {
func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) {
c.Perf.StartBlock("MIDDLEWARE", "Load common website data") c.Perf.StartBlock("MIDDLEWARE", "Load common website data")
defer c.Perf.EndBlock() defer c.Perf.EndBlock()
// get project // get project
{ {
slug := "" slug := ""

View File

@ -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<videoid>[^/&]+)`)
var YoutubeShortRegex = regexp.MustCompile(`(?i)youtu\.be/(?P<videoid>[^/]+)`)
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,
}
}

302
src/website/user.go Normal file
View File

@ -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
}