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: "",
|
||||
},
|
||||
}
|
||||
|
|
|
@ -22,6 +22,7 @@ type HMNConfig struct {
|
|||
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