Profile page and timeline items

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

1
.gitignore vendored
View File

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

5
go.mod
View File

@ -7,13 +7,12 @@ require (
github.com/Masterminds/semver v1.5.0 // indirect
github.com/Masterminds/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
View File

@ -170,8 +170,6 @@ github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCV
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/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=

BIN
public/icons.ttf Executable file

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@ -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, "")
}

View File

@ -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) {

View File

@ -132,12 +132,12 @@ func BuildProjectSubmissionGuidelines() string {
}
/*
* Member
* User
*/
var RegexMember = regexp.MustCompile(`^/m/(?P<member>[^/]+)$`)
var RegexUserProfile = regexp.MustCompile(`^/m/(?P<username>[^/]+)$`)
func BuildMember(username string) string {
func BuildUserProfile(username string) string {
defer CatchPanic()
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 {

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

@ -0,0 +1,18 @@
package models
import (
"github.com/google/uuid"
)
type Asset struct {
ID uuid.UUID `db:"id"`
UploaderID *int `db:"uploader_id"`
S3Key string `db:"s3_key"`
Filename string `db:"filename"`
Size int `db:"size"`
MimeType string `db:"mime_type"`
Sha1Sum string `db:"sha1sum"`
Width int `db:"width"`
Height int `db:"height"`
}

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

@ -0,0 +1,15 @@
package models
import (
"time"
)
type DiscordMessage struct {
ID string `db:"id"`
ChannelID string `db:"channel_id"`
GuildID *string `db:"guild_id"`
Url string `db:"url"`
UserID string `db:"user_id"`
SentAt time.Time `db:"sent_at"`
SnippetCreated bool `db:"snippet_created"`
}

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

@ -0,0 +1,11 @@
package models
type Link struct {
ID int `db:"id"`
Key string `db:"key"`
Name *string `db:"name"`
Value string `db:"value"`
Ordering int `db:"ordering"`
UserID *int `db:"user_id"`
ProjectID *int `db:"project_id"`
}

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

@ -0,0 +1,23 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Snippet struct {
ID int `db:"id"`
OwnerID int `db:"owner_id"`
When time.Time `db:"when"`
Description string `db:"description"`
DescriptionHtml string `db:"_description_html"`
Url *string `db:"url"`
AssetID *uuid.UUID `db:"asset_id"`
EditedOnWebsite bool `db:"edited_on_website"`
DiscordMessageID *string `db:"discord_message_id"`
}

View File

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

View File

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

View File

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

View File

@ -4,7 +4,7 @@
{{ if .User.IsSuperuser }}
<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>

View File

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

View File

@ -72,9 +72,18 @@ func names(ts []*template.Template) []string {
}
var HMNTemplateFuncs = template.FuncMap{
"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 {

View File

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

View File

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

View File

@ -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 := ""

View File

@ -0,0 +1,170 @@
package website
import (
"html/template"
"regexp"
"strings"
"git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/templates"
)
var TimelineTypeMap = map[models.CategoryKind][]templates.TimelineType{
// No parent, Has parent
models.CatKindBlog: []templates.TimelineType{templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment},
models.CatKindForum: []templates.TimelineType{templates.TimelineTypeForumThread, templates.TimelineTypeForumReply},
models.CatKindWiki: []templates.TimelineType{templates.TimelineTypeWikiCreate, templates.TimelineTypeWikiTalk},
models.CatKindLibraryResource: []templates.TimelineType{templates.TimelineTypeLibraryComment, templates.TimelineTypeLibraryComment},
}
var TimelineItemClassMap = map[templates.TimelineType]string{
templates.TimelineTypeUnknown: "",
templates.TimelineTypeForumThread: "forums",
templates.TimelineTypeForumReply: "forums",
templates.TimelineTypeBlogPost: "blogs",
templates.TimelineTypeBlogComment: "blogs",
templates.TimelineTypeWikiCreate: "wiki",
templates.TimelineTypeWikiEdit: "wiki",
templates.TimelineTypeWikiTalk: "wiki",
templates.TimelineTypeLibraryComment: "library",
templates.TimelineTypeSnippetImage: "snippets",
templates.TimelineTypeSnippetVideo: "snippets",
templates.TimelineTypeSnippetAudio: "snippets",
templates.TimelineTypeSnippetYoutube: "snippets",
}
var TimelineTypeTitleMap = map[templates.TimelineType]string{
templates.TimelineTypeUnknown: "",
templates.TimelineTypeForumThread: "New forums thread",
templates.TimelineTypeForumReply: "Forum reply",
templates.TimelineTypeBlogPost: "New blog post",
templates.TimelineTypeBlogComment: "Blog comment",
templates.TimelineTypeWikiCreate: "New wiki article",
templates.TimelineTypeWikiEdit: "Wiki edit",
templates.TimelineTypeWikiTalk: "Wiki talk",
templates.TimelineTypeLibraryComment: "Library comment",
templates.TimelineTypeSnippetImage: "Snippet",
templates.TimelineTypeSnippetVideo: "Snippet",
templates.TimelineTypeSnippetAudio: "Snippet",
templates.TimelineTypeSnippetYoutube: "Snippet",
}
func PostToTimelineItem(lineageBuilder *models.CategoryLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, libraryResource *models.LibraryResource, owner *models.User, currentTheme string) templates.TimelineItem {
itemType := templates.TimelineTypeUnknown
typeByCatKind, found := TimelineTypeMap[post.CategoryKind]
if found {
hasParent := 0
if post.ParentID != nil {
hasParent = 1
}
itemType = typeByCatKind[hasParent]
}
libraryResourceId := 0
if libraryResource != nil {
libraryResourceId = libraryResource.ID
}
return templates.TimelineItem{
Type: itemType,
TypeTitle: TimelineTypeTitleMap[itemType],
Class: TimelineItemClassMap[itemType],
Date: post.PostDate,
Url: UrlForGenericPost(post, lineageBuilder.GetSubforumLineageSlugs(post.CategoryID), thread.Title, libraryResourceId, project.Slug),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: templates.UserDisplayName(owner),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
Description: "", // NOTE(asaf): No description for posts
Title: thread.Title,
Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, libraryResource),
}
}
func PostVersionToWikiTimelineItem(lineageBuilder *models.CategoryLineageBuilder, version *models.PostVersion, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem {
return templates.TimelineItem{
Type: templates.TimelineTypeWikiEdit,
TypeTitle: TimelineTypeTitleMap[templates.TimelineTypeWikiEdit],
Class: TimelineItemClassMap[templates.TimelineTypeWikiEdit],
Date: version.EditDate,
Url: hmnurl.BuildWikiArticle(project.Slug, thread.ID, thread.Title),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: templates.UserDisplayName(owner),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
Description: "", // NOTE(asaf): No description for posts
Title: thread.Title,
Breadcrumbs: PostBreadcrumbs(lineageBuilder, project, post, nil),
}
}
var YoutubeRegex = regexp.MustCompile(`(?i)youtube\.com/watch\?.*v=(?P<videoid>[^/&]+)`)
var YoutubeShortRegex = regexp.MustCompile(`(?i)youtu\.be/(?P<videoid>[^/]+)`)
func SnippetToTimelineItem(snippet *models.Snippet, asset *models.Asset, discordMessage *models.DiscordMessage, owner *models.User, currentTheme string) templates.TimelineItem {
itemType := templates.TimelineTypeUnknown
youtubeId := ""
assetUrl := ""
width := 0
height := 0
discordMessageUrl := ""
if asset == nil {
match := YoutubeRegex.FindStringSubmatch(*snippet.Url)
index := YoutubeRegex.SubexpIndex("videoid")
if match == nil {
match = YoutubeShortRegex.FindStringSubmatch(*snippet.Url)
index = YoutubeShortRegex.SubexpIndex("videoid")
}
if match != nil {
youtubeId = match[index]
itemType = templates.TimelineTypeSnippetYoutube
}
} else {
if strings.HasPrefix(asset.MimeType, "image/") {
itemType = templates.TimelineTypeSnippetImage
} else if strings.HasPrefix(asset.MimeType, "video/") {
itemType = templates.TimelineTypeSnippetVideo
} else if strings.HasPrefix(asset.MimeType, "audio/") {
itemType = templates.TimelineTypeSnippetAudio
}
assetUrl = hmnurl.BuildS3Asset(asset.S3Key)
width = asset.Width
height = asset.Height
}
if discordMessage != nil {
discordMessageUrl = discordMessage.Url
}
return templates.TimelineItem{
Type: itemType,
Class: TimelineItemClassMap[itemType],
Date: snippet.When,
Url: hmnurl.BuildSnippet(snippet.ID),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: templates.UserDisplayName(owner),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
Description: template.HTML(snippet.DescriptionHtml),
DiscordMessageUrl: discordMessageUrl,
Width: width,
Height: height,
AssetUrl: assetUrl,
YoutubeID: youtubeId,
}
}

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

@ -0,0 +1,302 @@
package website
import (
"errors"
"net/http"
"sort"
"strings"
"git.handmade.network/hmn/hmn/src/db"
"git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates"
)
type UserProfileTemplateData struct {
templates.BaseData
ProfileUser templates.User
ProfileUserLinks []templates.Link
ProfileUserProjects []templates.Project
TimelineItems []templates.TimelineItem
NumForums int
NumBlogs int
NumWiki int
NumLibrary int
NumSnippets int
}
func UserProfile(c *RequestContext) ResponseData {
username, hasUsername := c.PathParams["username"]
if !hasUsername || len(strings.TrimSpace(username)) == 0 {
return FourOhFour(c)
}
username = strings.ToLower(username)
var profileUser *models.User
if c.CurrentUser != nil && strings.ToLower(c.CurrentUser.Username) == username {
profileUser = c.CurrentUser
} else {
c.Perf.StartBlock("SQL", "Fetch user")
userResult, err := db.QueryOne(c.Context(), c.Conn, models.User{},
`
SELECT $columns
FROM
auth_user
WHERE
LOWER(auth_user.username) = $1
`,
username,
)
c.Perf.EndBlock()
if err != nil {
if errors.Is(err, db.ErrNoMatchingRows) {
return FourOhFour(c)
} else {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch user: %s", username))
}
}
profileUser = userResult.(*models.User)
}
c.Perf.StartBlock("SQL", "Fetch user links")
type userLinkQuery struct {
UserLink models.Link `db:"link"`
}
userLinkQueryResult, err := db.Query(c.Context(), c.Conn, userLinkQuery{},
`
SELECT $columns
FROM
handmade_links as link
WHERE
link.user_id = $1
ORDER BY link.ordering ASC
`,
profileUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch links for user: %s", username))
}
userLinksSlice := userLinkQueryResult.ToSlice()
profileUserLinks := make([]templates.Link, 0, len(userLinksSlice))
for _, l := range userLinksSlice {
profileUserLinks = append(profileUserLinks, templates.LinkToTemplate(&l.(*userLinkQuery).UserLink))
}
c.Perf.EndBlock()
type projectQuery struct {
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch projects")
projectQueryResult, err := db.Query(c.Context(), c.Conn, projectQuery{},
`
SELECT $columns
FROM
handmade_project AS project
INNER JOIN handmade_project_groups AS project_groups ON project_groups.project_id = project.id
INNER JOIN auth_user_groups AS user_groups ON user_groups.group_id = project_groups.group_id
WHERE
user_groups.user_id = $1
AND ($2 OR (project.flags = 0 AND project.lifecycle = ANY ($3)))
`,
profileUser.ID,
(c.CurrentUser != nil && (profileUser == c.CurrentUser || c.CurrentUser.IsSuperuser)),
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch projects for user: %s", username))
}
projectQuerySlice := projectQueryResult.ToSlice()
templateProjects := make([]templates.Project, 0, len(projectQuerySlice))
for _, projectRow := range projectQuerySlice {
projectData := projectRow.(*projectQuery)
templateProjects = append(templateProjects, templates.ProjectToTemplate(&projectData.Project, c.Theme))
}
c.Perf.EndBlock()
type postQuery struct {
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
LibraryResource *models.LibraryResource `db:"lib_resource"`
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch posts")
postQueryResult, err := db.Query(c.Context(), c.Conn, postQuery{},
`
SELECT $columns
FROM
handmade_post AS post
INNER JOIN handmade_thread AS thread ON thread.id = post.thread_id
INNER JOIN handmade_project AS project ON project.id = post.project_id
LEFT JOIN handmade_libraryresource AS lib_resource ON lib_resource.category_id = post.category_id
WHERE
post.author_id = $1
AND project.lifecycle = ANY ($2)
`,
profileUser.ID,
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch posts for user: %s", username))
}
postQuerySlice := postQueryResult.ToSlice()
c.Perf.EndBlock()
type wikiEditQuery struct {
PostVersion models.PostVersion `db:"version"`
Post models.Post `db:"post"`
Thread models.Thread `db:"thread"`
Project models.Project `db:"project"`
}
c.Perf.StartBlock("SQL", "Fetch wiki edits")
wikiEditQueryResult, err := db.Query(c.Context(), c.Conn, wikiEditQuery{},
`
SELECT $columns
FROM
handmade_postversion AS version
INNER JOIN handmade_post AS post ON post.id = version.post_id
INNER JOIN handmade_thread AS thread on thread.id = post.thread_id
INNER JOIN handmade_project AS project ON project.id = post.project_id
WHERE
version.editor_id = $1
AND post.parent_id IS NULL
AND post.category_kind = $2
AND project.lifecycle = ANY ($3)
`,
profileUser.ID,
models.CatKindWiki,
models.VisibleProjectLifecycles,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch wiki edits for user: %s", username))
}
wikiEditQuerySlice := wikiEditQueryResult.ToSlice()
c.Perf.EndBlock()
type snippetQuery struct {
Snippet models.Snippet `db:"snippet"`
Asset *models.Asset `db:"asset"`
DiscordMessage *models.DiscordMessage `db:"discord_message"`
}
c.Perf.StartBlock("SQL", "Fetch snippets")
snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{},
`
SELECT $columns
FROM
handmade_snippet AS snippet
LEFT JOIN handmade_asset AS asset ON asset.id = snippet.asset_id
LEFT JOIN handmade_discordmessage AS discord_message ON discord_message.id = snippet.discord_message_id
WHERE
snippet.owner_id = $1
`,
profileUser.ID,
)
if err != nil {
return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets for user: %s", username))
}
snippetQuerySlice := snippetQueryResult.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch category tree")
categoryTree := models.GetFullCategoryTree(c.Context(), c.Conn)
lineageBuilder := models.MakeCategoryLineageBuilder(categoryTree)
c.Perf.EndBlock()
c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(postQuerySlice)+len(wikiEditQuerySlice)+len(snippetQuerySlice))
numForums := 0
numBlogs := 0
numWiki := len(wikiEditQuerySlice)
numLibrary := 0
numSnippets := len(snippetQuerySlice)
for _, postRow := range postQuerySlice {
postData := postRow.(*postQuery)
timelineItem := PostToTimelineItem(
lineageBuilder,
&postData.Post,
&postData.Thread,
&postData.Project,
postData.LibraryResource,
profileUser,
c.Theme,
)
switch timelineItem.Type {
case templates.TimelineTypeForumThread:
numForums += 1
case templates.TimelineTypeForumReply:
numForums += 1
case templates.TimelineTypeBlogPost:
numBlogs += 1
case templates.TimelineTypeBlogComment:
numBlogs += 1
case templates.TimelineTypeWikiCreate:
numWiki += 1
case templates.TimelineTypeWikiTalk:
numWiki += 1
case templates.TimelineTypeLibraryComment:
numLibrary += 1
}
if timelineItem.Type != templates.TimelineTypeUnknown {
timelineItems = append(timelineItems, timelineItem)
} else {
c.Logger.Warn().Int("post ID", postData.Post.ID).Msg("Unknown timeline item type for post")
}
}
for _, wikiEditRow := range wikiEditQuerySlice {
wikiEditData := wikiEditRow.(*wikiEditQuery)
timelineItem := PostVersionToWikiTimelineItem(
lineageBuilder,
&wikiEditData.PostVersion,
&wikiEditData.Post,
&wikiEditData.Thread,
&wikiEditData.Project,
profileUser,
c.Theme,
)
timelineItems = append(timelineItems, timelineItem)
}
for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery)
timelineItem := SnippetToTimelineItem(
&snippetData.Snippet,
snippetData.Asset,
snippetData.DiscordMessage,
profileUser,
c.Theme,
)
timelineItems = append(timelineItems, timelineItem)
}
c.Perf.StartBlock("PROFILE", "Sort timeline")
sort.Slice(timelineItems, func(i, j int) bool {
return timelineItems[j].Date.Before(timelineItems[i].Date)
})
c.Perf.EndBlock()
c.Perf.EndBlock()
baseData := getBaseData(c)
var res ResponseData
err = res.WriteTemplate("user_profile.html", UserProfileTemplateData{
BaseData: baseData,
ProfileUser: templates.UserToTemplate(profileUser, c.Theme),
ProfileUserLinks: profileUserLinks,
ProfileUserProjects: templateProjects,
TimelineItems: timelineItems,
NumForums: numForums,
NumBlogs: numBlogs,
NumWiki: numWiki,
NumLibrary: numLibrary,
NumSnippets: numSnippets,
}, c.Perf)
if err != nil {
panic(err)
}
return res
}