Profile page and timeline items
This commit is contained in:
		
							parent
							
								
									b6c611004c
								
							
						
					
					
						commit
						a4671c5fb5
					
				|  | @ -3,3 +3,4 @@ src/config/config.go | |||
| vendor/ | ||||
| dbclones/ | ||||
| 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/sprig v2.22.0+incompatible | ||||
| 	github.com/go-stack/stack v1.8.0 | ||||
| 	github.com/google/uuid v1.2.0 // indirect | ||||
| 	github.com/google/uuid v1.2.0 | ||||
| 	github.com/huandu/xstrings v1.3.2 // indirect | ||||
| 	github.com/imdario/mergo v0.3.12 // indirect | ||||
| 	github.com/jackc/pgconn v1.8.0 // indirect | ||||
| 	github.com/jackc/pgconn v1.8.0 | ||||
| 	github.com/jackc/pgtype v1.6.2 | ||||
| 	github.com/jackc/pgx/v4 v4.10.1 | ||||
| 	github.com/julienschmidt/httprouter v1.3.0 | ||||
| 	github.com/mitchellh/copystructure v1.1.1 // indirect | ||||
| 	github.com/rs/zerolog v1.20.0 | ||||
| 	github.com/spf13/cobra v1.1.3 | ||||
|  |  | |||
							
								
								
									
										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/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= | ||||
| github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= | ||||
| github.com/julienschmidt/httprouter v1.3.0 h1:U0609e9tgbseu3rBINet9P48AI/D3oJs4dN7jwJOQ1U= | ||||
| github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= | ||||
| github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= | ||||
| github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= | ||||
| github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= | ||||
|  |  | |||
										
											Binary file not shown.
										
									
								
							|  | @ -8947,7 +8947,7 @@ div.mark_as_read_toplevel_blog { | |||
| 
 | ||||
| @font-face { | ||||
|   font-family: icons; | ||||
|   src: url("/static/icon/icons.ttf?v=4"); } | ||||
|   src: url("/public/icons.ttf?v=4"); } | ||||
| 
 | ||||
| span.icon { | ||||
|   font-family: "icons"; } | ||||
|  |  | |||
|  | @ -18,4 +18,13 @@ var Config = HMNConfig{ | |||
| 		CookieDomain: ".handmade.local", | ||||
| 		CookieSecure: false, | ||||
| 	}, | ||||
| 	DigitalOcean: DigitalOceanConfig{ | ||||
| 		AssetsSpacesKey: "", | ||||
| 		AssetsSpacesSecret: "", | ||||
| 		AssetsSpacesRegion: "", | ||||
| 		AssetsSpacesEndpoint: "", | ||||
| 		AssetsSpacesBucket: "", | ||||
| 		AssetsPathPrefix: "", | ||||
| 		AssetsPublicUrlRoot: "", | ||||
| 	}, | ||||
| } | ||||
|  |  | |||
|  | @ -16,12 +16,13 @@ const ( | |||
| ) | ||||
| 
 | ||||
| type HMNConfig struct { | ||||
| 	Env      Environment | ||||
| 	Addr     string | ||||
| 	BaseUrl  string | ||||
| 	LogLevel zerolog.Level | ||||
| 	Postgres PostgresConfig | ||||
| 	Auth     AuthConfig | ||||
| 	Env          Environment | ||||
| 	Addr         string | ||||
| 	BaseUrl      string | ||||
| 	LogLevel     zerolog.Level | ||||
| 	Postgres     PostgresConfig | ||||
| 	Auth         AuthConfig | ||||
| 	DigitalOcean DigitalOceanConfig | ||||
| } | ||||
| 
 | ||||
| type PostgresConfig struct { | ||||
|  | @ -40,6 +41,16 @@ type AuthConfig struct { | |||
| 	CookieSecure bool | ||||
| } | ||||
| 
 | ||||
| type DigitalOceanConfig struct { | ||||
| 	AssetsSpacesKey      string | ||||
| 	AssetsSpacesSecret   string | ||||
| 	AssetsSpacesRegion   string | ||||
| 	AssetsSpacesEndpoint string | ||||
| 	AssetsSpacesBucket   string | ||||
| 	AssetsPathPrefix     string | ||||
| 	AssetsPublicUrlRoot  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) | ||||
| } | ||||
|  |  | |||
|  | @ -10,6 +10,7 @@ import ( | |||
| 	"git.handmade.network/hmn/hmn/src/config" | ||||
| 	"git.handmade.network/hmn/hmn/src/logging" | ||||
| 	"git.handmade.network/hmn/hmn/src/oops" | ||||
| 	"github.com/google/uuid" | ||||
| 	"github.com/jackc/pgtype" | ||||
| 	"github.com/jackc/pgx/v4" | ||||
| 	"github.com/jackc/pgx/v4/log/zerologadapter" | ||||
|  | @ -38,6 +39,8 @@ func typeIsQueryable(t reflect.Type) bool { | |||
| 
 | ||||
| 	if isRecognizedByPgtype { | ||||
| 		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
 | ||||
|  | @ -100,10 +103,12 @@ func (it *StructQueryIterator) Next() (interface{}, bool) { | |||
| 	// Better logging of panics in this confusing reflection process
 | ||||
| 	var currentField reflect.StructField | ||||
| 	var currentValue reflect.Value | ||||
| 	var currentIdx int | ||||
| 	defer func() { | ||||
| 		if r := recover(); r != nil { | ||||
| 			if currentValue.IsValid() { | ||||
| 				logging.Error(). | ||||
| 					Int("index", currentIdx). | ||||
| 					Str("field name", currentField.Name). | ||||
| 					Stringer("field type", currentField.Type). | ||||
| 					Interface("value", currentValue.Interface()). | ||||
|  | @ -120,6 +125,7 @@ func (it *StructQueryIterator) Next() (interface{}, bool) { | |||
| 	}() | ||||
| 
 | ||||
| 	for i, val := range vals { | ||||
| 		currentIdx = i | ||||
| 		if val == nil { | ||||
| 			continue | ||||
| 		} | ||||
|  |  | |||
|  | @ -10,9 +10,6 @@ import ( | |||
| 	"git.handmade.network/hmn/hmn/src/oops" | ||||
| ) | ||||
| 
 | ||||
| const StaticPath = "/public" | ||||
| const StaticThemePath = "/public/themes" | ||||
| 
 | ||||
| type Q struct { | ||||
| 	Name  string | ||||
| 	Value string | ||||
|  | @ -30,11 +27,13 @@ func QFromURL(u *url.URL) []Q { | |||
| 
 | ||||
| var baseUrlParsed url.URL | ||||
| var cacheBust string | ||||
| var S3BaseUrl string | ||||
| var isTest bool | ||||
| 
 | ||||
| func init() { | ||||
| 	SetGlobalBaseUrl(config.Config.BaseUrl) | ||||
| 	SetCacheBust(fmt.Sprint(time.Now().Unix())) | ||||
| 	SetS3BaseUrl(config.Config.DigitalOcean.AssetsPublicUrlRoot) | ||||
| } | ||||
| 
 | ||||
| func SetGlobalBaseUrl(fullBaseUrl string) { | ||||
|  | @ -53,6 +52,10 @@ func SetCacheBust(newCacheBust string) { | |||
| 	cacheBust = newCacheBust | ||||
| } | ||||
| 
 | ||||
| func SetS3BaseUrl(base string) { | ||||
| 	S3BaseUrl = base | ||||
| } | ||||
| 
 | ||||
| func Url(path string, query []Q) string { | ||||
| 	return ProjectUrl(path, query, "") | ||||
| } | ||||
|  |  | |||
|  | @ -80,8 +80,12 @@ func TestStaticPages(t *testing.T) { | |||
| 	AssertRegexMatch(t, BuildProjectSubmissionGuidelines(), RegexProjectSubmissionGuidelines, nil) | ||||
| } | ||||
| 
 | ||||
| func TestMember(t *testing.T) { | ||||
| 	AssertRegexMatch(t, BuildMember("test"), RegexMember, map[string]string{"member": "test"}) | ||||
| func TestUserProfile(t *testing.T) { | ||||
| 	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) { | ||||
|  |  | |||
|  | @ -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() | ||||
| 	if len(username) == 0 { | ||||
| 		panic(oops.New(nil, "Username must not be blank")) | ||||
|  | @ -145,6 +145,17 @@ func BuildMember(username string) string { | |||
| 	return Url("/m/"+username, nil) | ||||
| } | ||||
| 
 | ||||
| /* | ||||
| * Snippets | ||||
|  */ | ||||
| 
 | ||||
| var RegexSnippet = regexp.MustCompile(`^/snippet/(?P<snippetid>\d+)$`) | ||||
| 
 | ||||
| func BuildSnippet(snippetId int) string { | ||||
| 	defer CatchPanic() | ||||
| 	return Url("/snippet/"+strconv.Itoa(snippetId), nil) | ||||
| } | ||||
| 
 | ||||
| /* | ||||
| * Feed | ||||
|  */ | ||||
|  | @ -683,6 +694,12 @@ func BuildProjectCSS(color string) string { | |||
| 	return Url("/assets/project.css", []Q{{"color", color}}) | ||||
| } | ||||
| 
 | ||||
| // NOTE(asaf): No Regex or tests for remote assets, since we don't parse it ourselves
 | ||||
| func BuildS3Asset(s3key string) string { | ||||
| 	defer CatchPanic() | ||||
| 	return fmt.Sprintf("%s%s", S3BaseUrl, s3key) | ||||
| } | ||||
| 
 | ||||
| var RegexPublic = regexp.MustCompile("^/public/.+$") | ||||
| 
 | ||||
| func BuildPublic(filepath string, cachebust bool) string { | ||||
|  |  | |||
|  | @ -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-family: icons; | ||||
|   src: url("/static/icon/icons.ttf?v=4"); | ||||
|   src: url("/public/icons.ttf?v=4"); | ||||
| } | ||||
| 
 | ||||
| span.icon { | ||||
|  |  | |||
|  | @ -3,6 +3,7 @@ package templates | |||
| import ( | ||||
| 	"html/template" | ||||
| 	"net" | ||||
| 	"regexp" | ||||
| 
 | ||||
| 	"git.handmade.network/hmn/hmn/src/hmnurl" | ||||
| 	"git.handmade.network/hmn/hmn/src/models" | ||||
|  | @ -71,17 +72,22 @@ var LifecycleBadgeStrings = map[models.ProjectLifecycle]string{ | |||
| 	models.ProjectLifecycleLTS:              "Complete", | ||||
| } | ||||
| 
 | ||||
| func ProjectToTemplate(p *models.Project, theme string) Project { | ||||
| 	logo := p.LogoLight | ||||
| 	if theme == "dark" { | ||||
| 		logo = p.LogoDark | ||||
| 	} | ||||
| func ProjectUrl(p *models.Project) string { | ||||
| 	var url string | ||||
| 	if p.Lifecycle == models.ProjectLifecycleUnapproved || p.Lifecycle == models.ProjectLifecycleApprovalRequired { | ||||
| 		url = hmnurl.BuildProjectNotApproved(p.Slug) | ||||
| 	} else { | ||||
| 		url = hmnurl.BuildProjectHomepage(p.Slug) | ||||
| 	} | ||||
| 	return url | ||||
| } | ||||
| 
 | ||||
| func ProjectToTemplate(p *models.Project, theme string) Project { | ||||
| 	logo := p.LogoLight | ||||
| 	if theme == "dark" { | ||||
| 		logo = p.LogoDark | ||||
| 	} | ||||
| 	url := ProjectUrl(p) | ||||
| 	return Project{ | ||||
| 		Name:              p.Name, | ||||
| 		Subdomain:         p.Subdomain(), | ||||
|  | @ -115,36 +121,49 @@ func ThreadToTemplate(t *models.Thread) Thread { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| func UserToTemplate(u *models.User, currentTheme string) User { | ||||
| 	// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function.
 | ||||
| func UserAvatarUrl(u *models.User, currentTheme string) string { | ||||
| 	if currentTheme == "" { | ||||
| 		currentTheme = "light" | ||||
| 	} | ||||
| 
 | ||||
| 	avatar := "" | ||||
| 	if u.Avatar != nil && len(*u.Avatar) > 0 { | ||||
| 		avatar = hmnurl.BuildUserFile(*u.Avatar) | ||||
| 	} else { | ||||
| 		avatar = hmnurl.BuildTheme("empty-avatar.svg", currentTheme, true) | ||||
| 	} | ||||
| 	return avatar | ||||
| } | ||||
| 
 | ||||
| func UserDisplayName(u *models.User) string { | ||||
| 	name := u.Name | ||||
| 	if u.Name == "" { | ||||
| 		name = u.Username | ||||
| 	} | ||||
| 	return name | ||||
| } | ||||
| 
 | ||||
| func UserToTemplate(u *models.User, currentTheme string) User { | ||||
| 	// TODO: Handle deleted users. Maybe not here, but if not, at call sites of this function.
 | ||||
| 
 | ||||
| 	email := "" | ||||
| 	if u.ShowEmail { | ||||
| 		// TODO(asaf): Always show email to admins
 | ||||
| 		email = u.Email | ||||
| 	} | ||||
| 
 | ||||
| 	return User{ | ||||
| 		ID:          u.ID, | ||||
| 		Username:    u.Username, | ||||
| 		Email:       u.Email, | ||||
| 		Email:       email, | ||||
| 		IsSuperuser: u.IsSuperuser, | ||||
| 		IsStaff:     u.IsStaff, | ||||
| 
 | ||||
| 		Name:       name, | ||||
| 		Name:       UserDisplayName(u), | ||||
| 		Blurb:      u.Blurb, | ||||
| 		Signature:  u.Signature, | ||||
| 		AvatarUrl:  avatar, | ||||
| 		ProfileUrl: hmnurl.BuildMember(u.Username), | ||||
| 		DateJoined: u.DateJoined, | ||||
| 		AvatarUrl:  UserAvatarUrl(u, currentTheme), | ||||
| 		ProfileUrl: hmnurl.BuildUserProfile(u.Username), | ||||
| 
 | ||||
| 		DarkTheme:     u.DarkTheme, | ||||
| 		Timezone:      u.Timezone, | ||||
|  | @ -157,6 +176,53 @@ func UserToTemplate(u *models.User, currentTheme string) User { | |||
| 	} | ||||
| } | ||||
| 
 | ||||
| var RegexServiceYoutube = regexp.MustCompile(`youtube\.com/(c/)?(?P<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 { | ||||
| 	if s == nil { | ||||
| 		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 }} | ||||
|                 <a class="admin-panel" href="{{ .Header.AdminUrl }}"><span class="icon-settings"> Admin</span></a> | ||||
|             {{ 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> | ||||
|         {{ else }} | ||||
|             <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{ | ||||
| 	"add": func(a int, b ...int) int { | ||||
| 		for _, num := range b { | ||||
| 			a += num | ||||
| 		} | ||||
| 		return a | ||||
| 	}, | ||||
| 	"absolutedate": func(t time.Time) string { | ||||
| 		return t.UTC().Format("January 2, 2006, 3:04pm") | ||||
| 	}, | ||||
| 	"absoluteshortdate": func(t time.Time) string { | ||||
| 		return t.UTC().Format("January 2, 2006") | ||||
| 	}, | ||||
| 	"rfc3339": func(t time.Time) string { | ||||
| 		return t.UTC().Format(time.RFC3339) | ||||
| 	}, | ||||
|  | @ -166,6 +175,43 @@ var HMNTemplateFuncs = template.FuncMap{ | |||
| 			Classes: classes, | ||||
| 		} | ||||
| 	}, | ||||
| 
 | ||||
| 	"timelinepostitem": func(item TimelineItem) bool { | ||||
| 		if item.Type == TimelineTypeForumThread || | ||||
| 			item.Type == TimelineTypeForumReply || | ||||
| 			item.Type == TimelineTypeBlogPost || | ||||
| 			item.Type == TimelineTypeBlogComment || | ||||
| 			item.Type == TimelineTypeWikiCreate || | ||||
| 			item.Type == TimelineTypeWikiEdit || | ||||
| 			item.Type == TimelineTypeWikiTalk || | ||||
| 			item.Type == TimelineTypeLibraryComment { | ||||
| 
 | ||||
| 			return true | ||||
| 		} | ||||
| 		return false | ||||
| 	}, | ||||
| 	"timelinesnippetitem": func(item TimelineItem) bool { | ||||
| 		if item.Type == TimelineTypeSnippetImage || | ||||
| 			item.Type == TimelineTypeSnippetVideo || | ||||
| 			item.Type == TimelineTypeSnippetAudio || | ||||
| 			item.Type == TimelineTypeSnippetYoutube { | ||||
| 
 | ||||
| 			return true | ||||
| 		} | ||||
| 		return false | ||||
| 	}, | ||||
| 	"snippetvideo": func(snippet TimelineItem) bool { | ||||
| 		return snippet.Type == TimelineTypeSnippetVideo | ||||
| 	}, | ||||
| 	"snippetaudio": func(snippet TimelineItem) bool { | ||||
| 		return snippet.Type == TimelineTypeSnippetAudio | ||||
| 	}, | ||||
| 	"snippetimage": func(snippet TimelineItem) bool { | ||||
| 		return snippet.Type == TimelineTypeSnippetImage | ||||
| 	}, | ||||
| 	"snippetyoutube": func(snippet TimelineItem) bool { | ||||
| 		return snippet.Type == TimelineTypeSnippetYoutube | ||||
| 	}, | ||||
| } | ||||
| 
 | ||||
| type ErrInvalidHexColor struct { | ||||
|  |  | |||
|  | @ -27,7 +27,7 @@ type BaseData struct { | |||
| 
 | ||||
| type Header struct { | ||||
| 	AdminUrl           string | ||||
| 	MemberSettingsUrl  string | ||||
| 	UserSettingsUrl    string | ||||
| 	LoginActionUrl     string | ||||
| 	LogoutActionUrl    string | ||||
| 	RegisterUrl        string | ||||
|  | @ -124,6 +124,7 @@ type User struct { | |||
| 	Blurb      string | ||||
| 	Bio        string | ||||
| 	Signature  string | ||||
| 	DateJoined time.Time | ||||
| 	AvatarUrl  string | ||||
| 	ProfileUrl string | ||||
| 
 | ||||
|  | @ -137,6 +138,14 @@ type User struct { | |||
| 	DiscordDeleteSnippetOnMessageDelete bool | ||||
| } | ||||
| 
 | ||||
| type Link struct { | ||||
| 	Key             string | ||||
| 	ServiceName     string | ||||
| 	ServiceUserData string | ||||
| 	Name            string | ||||
| 	Value           string | ||||
| } | ||||
| 
 | ||||
| type OpenGraphItem struct { | ||||
| 	Property string | ||||
| 	Name     string | ||||
|  | @ -197,6 +206,51 @@ type ThreadListItem struct { | |||
| 	Content string | ||||
| } | ||||
| 
 | ||||
| type TimelineType int | ||||
| 
 | ||||
| const ( | ||||
| 	TimelineTypeUnknown TimelineType = iota | ||||
| 
 | ||||
| 	TimelineTypeForumThread | ||||
| 	TimelineTypeForumReply | ||||
| 
 | ||||
| 	TimelineTypeBlogPost | ||||
| 	TimelineTypeBlogComment | ||||
| 
 | ||||
| 	TimelineTypeWikiCreate | ||||
| 	TimelineTypeWikiEdit | ||||
| 	TimelineTypeWikiTalk | ||||
| 
 | ||||
| 	TimelineTypeLibraryComment | ||||
| 
 | ||||
| 	TimelineTypeSnippetImage | ||||
| 	TimelineTypeSnippetVideo | ||||
| 	TimelineTypeSnippetAudio | ||||
| 	TimelineTypeSnippetYoutube | ||||
| ) | ||||
| 
 | ||||
| type TimelineItem struct { | ||||
| 	Type      TimelineType | ||||
| 	TypeTitle string | ||||
| 	Class     string | ||||
| 	Date      time.Time | ||||
| 	Url       string | ||||
| 
 | ||||
| 	OwnerAvatarUrl string | ||||
| 	OwnerName      string | ||||
| 	OwnerUrl       string | ||||
| 	Description    template.HTML | ||||
| 
 | ||||
| 	DiscordMessageUrl string | ||||
| 	Width             int | ||||
| 	Height            int | ||||
| 	AssetUrl          string | ||||
| 	YoutubeID         string | ||||
| 
 | ||||
| 	Title       string | ||||
| 	Breadcrumbs []Breadcrumb | ||||
| } | ||||
| 
 | ||||
| type ProjectCardData struct { | ||||
| 	Project *Project | ||||
| 	Classes string | ||||
|  |  | |||
|  | @ -47,6 +47,35 @@ var PostTypePrefix = map[templates.PostType]string{ | |||
| 	templates.PostTypeLibraryComment: "Library comment", | ||||
| } | ||||
| 
 | ||||
| func PostBreadcrumbs(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, post *models.Post, libraryResource *models.LibraryResource) []templates.Breadcrumb { | ||||
| 	var result []templates.Breadcrumb | ||||
| 	result = append(result, templates.Breadcrumb{ | ||||
| 		Name: project.Name, | ||||
| 		Url:  hmnurl.BuildProjectHomepage(project.Slug), | ||||
| 	}) | ||||
| 	result = append(result, templates.Breadcrumb{ | ||||
| 		Name: CategoryKindDisplayNames[post.CategoryKind], | ||||
| 		Url:  BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), | ||||
| 	}) | ||||
| 	switch post.CategoryKind { | ||||
| 	case models.CatKindForum: | ||||
| 		subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) | ||||
| 		slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) | ||||
| 		for i, subforum := range subforums { | ||||
| 			result = append(result, templates.Breadcrumb{ | ||||
| 				Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
 | ||||
| 				Url:  hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), | ||||
| 			}) | ||||
| 		} | ||||
| 	case models.CatKindLibraryResource: | ||||
| 		result = append(result, templates.Breadcrumb{ | ||||
| 			Name: libraryResource.Name, | ||||
| 			Url:  hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID), | ||||
| 		}) | ||||
| 	} | ||||
| 	return result | ||||
| } | ||||
| 
 | ||||
| // NOTE(asaf): THIS DOESN'T HANDLE WIKI EDIT ITEMS. Wiki edits are PostTextVersions, not Posts.
 | ||||
| func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *models.Project, thread *models.Thread, post *models.Post, user *models.User, libraryResource *models.LibraryResource, unread bool, includeBreadcrumbs bool, currentTheme string) templates.PostListItem { | ||||
| 	var result templates.PostListItem | ||||
|  | @ -75,30 +104,7 @@ func MakePostListItem(lineageBuilder *models.CategoryLineageBuilder, project *mo | |||
| 	result.PostTypePrefix = PostTypePrefix[result.PostType] | ||||
| 
 | ||||
| 	if includeBreadcrumbs { | ||||
| 		result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ | ||||
| 			Name: project.Name, | ||||
| 			Url:  hmnurl.BuildProjectHomepage(project.Slug), | ||||
| 		}) | ||||
| 		result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ | ||||
| 			Name: CategoryKindDisplayNames[post.CategoryKind], | ||||
| 			Url:  BuildProjectMainCategoryUrl(project.Slug, post.CategoryKind), | ||||
| 		}) | ||||
| 		switch post.CategoryKind { | ||||
| 		case models.CatKindForum: | ||||
| 			subforums := lineageBuilder.GetSubforumLineage(post.CategoryID) | ||||
| 			slugs := lineageBuilder.GetSubforumLineageSlugs(post.CategoryID) | ||||
| 			for i, subforum := range subforums { | ||||
| 				result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ | ||||
| 					Name: *subforum.Name, // NOTE(asaf): All subforum categories must have names.
 | ||||
| 					Url:  hmnurl.BuildForumCategory(project.Slug, slugs[0:i+1], 1), | ||||
| 				}) | ||||
| 			} | ||||
| 		case models.CatKindLibraryResource: | ||||
| 			result.Breadcrumbs = append(result.Breadcrumbs, templates.Breadcrumb{ | ||||
| 				Name: libraryResource.Name, | ||||
| 				Url:  hmnurl.BuildLibraryResource(project.Slug, libraryResource.ID), | ||||
| 			}) | ||||
| 		} | ||||
| 		result.Breadcrumbs = PostBreadcrumbs(lineageBuilder, project, post, libraryResource) | ||||
| 	} | ||||
| 
 | ||||
| 	return result | ||||
|  |  | |||
|  | @ -103,16 +103,20 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt | |||
| 	staticPages.GET(hmnurl.RegexMonthlyUpdatePolicy, MonthlyUpdatePolicy) | ||||
| 	staticPages.GET(hmnurl.RegexProjectSubmissionGuidelines, ProjectSubmissionGuidelines) | ||||
| 
 | ||||
| 	// TODO(asaf): Have separate middleware for HMN-only routes and any-project routes
 | ||||
| 	// NOTE(asaf): HMN-only routes:
 | ||||
| 	mainRoutes.GET(hmnurl.RegexFeed, Feed) | ||||
| 	mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) | ||||
| 
 | ||||
| 	mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) | ||||
| 	mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) | ||||
| 
 | ||||
| 	// NOTE(asaf): Any-project routes:
 | ||||
| 	mainRoutes.GET(hmnurl.RegexForumThread, ForumThread) | ||||
| 	mainRoutes.GET(hmnurl.RegexForumCategory, ForumCategory) | ||||
| 
 | ||||
| 	mainRoutes.GET(hmnurl.RegexProjectCSS, ProjectCSS) | ||||
| 
 | ||||
| 	// Other
 | ||||
| 	mainRoutes.AnyMethod(hmnurl.RegexCatchAll, FourOhFour) | ||||
| 
 | ||||
| 	return router | ||||
|  | @ -139,7 +143,7 @@ func getBaseData(c *RequestContext) templates.BaseData { | |||
| 		IsProjectPage: !c.CurrentProject.IsHMN(), | ||||
| 		Header: templates.Header{ | ||||
| 			AdminUrl:           hmnurl.BuildHomepage(), // TODO(asaf)
 | ||||
| 			MemberSettingsUrl:  hmnurl.BuildHomepage(), // TODO(asaf)
 | ||||
| 			UserSettingsUrl:    hmnurl.BuildHomepage(), // TODO(asaf)
 | ||||
| 			LoginActionUrl:     hmnurl.BuildLoginAction(c.FullUrl()), | ||||
| 			LogoutActionUrl:    hmnurl.BuildLogoutAction(), | ||||
| 			RegisterUrl:        hmnurl.BuildHomepage(), // TODO(asaf)
 | ||||
|  | @ -256,6 +260,7 @@ func FourOhFour(c *RequestContext) ResponseData { | |||
| func LoadCommonWebsiteData(c *RequestContext) (bool, ResponseData) { | ||||
| 	c.Perf.StartBlock("MIDDLEWARE", "Load common website data") | ||||
| 	defer c.Perf.EndBlock() | ||||
| 
 | ||||
| 	// get project
 | ||||
| 	{ | ||||
| 		slug := "" | ||||
|  |  | |||
|  | @ -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