Profile page and timeline items
This commit is contained in:
parent
b6c611004c
commit
a4671c5fb5
|
@ -3,3 +3,4 @@ src/config/config.go
|
||||||
vendor/
|
vendor/
|
||||||
dbclones/
|
dbclones/
|
||||||
coverage.out
|
coverage.out
|
||||||
|
public/media/
|
||||||
|
|
5
go.mod
5
go.mod
|
@ -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
2
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/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=
|
||||||
|
|
Binary file not shown.
|
@ -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"; }
|
||||||
|
|
|
@ -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: "",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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, "")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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"`
|
||||||
|
}
|
|
@ -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 {
|
||||||
|
|
|
@ -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 ""
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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> — {{ 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 }}
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 := ""
|
||||||
|
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
Loading…
Reference in New Issue