Rejigger timelines to avoid explicit types

This commit is contained in:
Ben Visness 2021-10-23 17:28:06 -05:00
parent 6176744462
commit 09e6a15085
18 changed files with 357 additions and 326 deletions

View File

@ -1,6 +1,6 @@
#!/bin/bash #!/bin/bash
set -eou pipefail set -euxo pipefail
# This script is for use in local development only. It wipes the existing db, # This script is for use in local development only. It wipes the existing db,
# creates a new empty one, runs the initial migration to create the schema, # creates a new empty one, runs the initial migration to create the schema,
@ -9,8 +9,8 @@ set -eou pipefail
# TODO(opensource): We should adapt Asaf's seedfile command and then delete this. # TODO(opensource): We should adapt Asaf's seedfile command and then delete this.
THIS_PATH=$(pwd) THIS_PATH=$(pwd)
BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta' #BETA_PATH='/mnt/c/Users/bvisn/Developer/handmade/handmade-beta'
# BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta' BETA_PATH='/Users/benvisness/Developer/handmade/handmade-beta'
pushd $BETA_PATH pushd $BETA_PATH
docker-compose down -v docker-compose down -v
@ -19,4 +19,4 @@ pushd $BETA_PATH
docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\"" docker-compose exec postgres bash -c "psql -U postgres -c \"CREATE ROLE hmn CREATEDB LOGIN PASSWORD 'password';\""
popd popd
go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-09-06 go run src/main.go seedfile local/backups/hmn_pg_dump_live_2021-10-23

View File

@ -1,14 +1,9 @@
const TimelineTypes = { const TimelineMediaTypes = {
UNKNOWN: 0, IMAGE: 1,
FORUM_THREAD: 1, VIDEO: 2,
FORUM_REPLY: 2, AUDIO: 3,
BLOG_POST: 3, EMBED: 4,
BLOG_COMMENT: 4, }
SNIPPET_IMAGE: 5,
SNIPPET_VIDEO: 6,
SNIPPET_AUDIO: 7,
SNIPPET_YOUTUBE: 8
};
const showcaseItemTemplate = makeTemplateCloner("showcase_item"); const showcaseItemTemplate = makeTemplateCloner("showcase_item");
const modalTemplate = makeTemplateCloner("timeline_modal"); const modalTemplate = makeTemplateCloner("timeline_modal");
@ -39,10 +34,10 @@ function makeShowcaseItem(timelineItem) {
let addThumbnailFunc = () => {}; let addThumbnailFunc = () => {};
let createModalContentFunc = () => {}; let createModalContentFunc = () => {};
switch (timelineItem.type) { switch (timelineItem.media_type) {
case TimelineTypes.SNIPPET_IMAGE: case TimelineMediaTypes.IMAGE:
addThumbnailFunc = () => { addThumbnailFunc = () => {
itemEl.thumbnail.style.backgroundImage = `url('${timelineItem.asset_url}')`; itemEl.thumbnail.style.backgroundImage = `url('${timelineItem.thumbnail_url}')`;
}; };
createModalContentFunc = () => { createModalContentFunc = () => {
@ -53,10 +48,10 @@ function makeShowcaseItem(timelineItem) {
}; };
break; break;
case TimelineTypes.SNIPPET_VIDEO: case TimelineMediaTypes.VIDEO:
addThumbnailFunc = () => { addThumbnailFunc = () => {
const video = document.createElement('video'); const video = document.createElement('video');
video.src = timelineItem.asset_url; video.src = timelineItem.asset_url; // TODO: Use image thumbnails
video.controls = false; video.controls = false;
video.classList.add('h-100'); video.classList.add('h-100');
video.preload = 'metadata'; video.preload = 'metadata';
@ -73,7 +68,7 @@ function makeShowcaseItem(timelineItem) {
}; };
break; break;
case TimelineTypes.SNIPPET_AUDIO: case TimelineMediaTypes.AUDIO:
createModalContentFunc = () => { createModalContentFunc = () => {
const modalAudio = document.createElement('audio'); const modalAudio = document.createElement('audio');
modalAudio.src = timelineItem.asset_url; modalAudio.src = timelineItem.asset_url;

View File

@ -9364,12 +9364,12 @@ span.icon-rss::before {
display: block; display: block;
max-width: 100%; max-width: 100%;
max-height: 80vh; } max-height: 80vh; }
.timeline-item .timeline-content-box.youtube { .timeline-item .timeline-content-box.embed {
position: relative; position: relative;
width: 100%; width: 100%;
height: 0; height: 0;
padding-bottom: 56.25%; } padding-bottom: 56.25%; }
.timeline-item .timeline-content-box.youtube > iframe { .timeline-item .timeline-content-box.embed > iframe {
position: absolute; position: absolute;
top: 0; top: 0;
left: 0; left: 0;

View File

@ -1,17 +1,4 @@
.timeline { .timeline {
&.no-forums .forums {
display: none;
}
&.no-blogs .blogs {
display: none;
}
&.no-library .library {
display: none;
}
&.no-snippets .snippets {
display: none;
}
.timeline-item { .timeline-item {
@include usevar(background-color, card-background); @include usevar(background-color, card-background);
@include usevar(color, main-color); @include usevar(color, main-color);
@ -41,7 +28,7 @@
max-height: 80vh; max-height: 80vh;
} }
.timeline-content-box.youtube { .timeline-content-box.embed {
// NOTE(asaf): CSS trick to get an iframe to auto resize // NOTE(asaf): CSS trick to get an iframe to auto resize
position: relative; position: relative;
width: 100%; width: 100%;

View File

@ -254,10 +254,6 @@ func TimelineItemsToJSON(items []TimelineItem) string {
} }
builder.WriteRune('{') builder.WriteRune('{')
builder.WriteString(`"type":`)
builder.WriteString(strconv.Itoa(int(item.Type)))
builder.WriteRune(',')
builder.WriteString(`"date":`) builder.WriteString(`"date":`)
builder.WriteString(strconv.FormatInt(item.Date.UTC().Unix(), 10)) builder.WriteString(strconv.FormatInt(item.Date.UTC().Unix(), 10))
builder.WriteRune(',') builder.WriteRune(',')
@ -288,16 +284,36 @@ func TimelineItemsToJSON(items []TimelineItem) string {
builder.WriteString(item.Url) builder.WriteString(item.Url)
builder.WriteString(`",`) builder.WriteString(`",`)
var mediaType TimelineItemMediaType
var assetUrl string
var thumbnailUrl string
var width, height int
if len(item.EmbedMedia) > 0 {
mediaType = item.EmbedMedia[0].Type
assetUrl = item.EmbedMedia[0].AssetUrl
thumbnailUrl = item.EmbedMedia[0].ThumbnailUrl
width = item.EmbedMedia[0].Width
height = item.EmbedMedia[0].Height
}
builder.WriteString(`"media_type":`)
builder.WriteString(strconv.Itoa(int(mediaType)))
builder.WriteRune(',')
builder.WriteString(`"width":`) builder.WriteString(`"width":`)
builder.WriteString(strconv.Itoa(item.Width)) builder.WriteString(strconv.Itoa(width))
builder.WriteRune(',') builder.WriteRune(',')
builder.WriteString(`"height":`) builder.WriteString(`"height":`)
builder.WriteString(strconv.Itoa(item.Height)) builder.WriteString(strconv.Itoa(height))
builder.WriteRune(',') builder.WriteRune(',')
builder.WriteString(`"asset_url":"`) builder.WriteString(`"asset_url":"`)
builder.WriteString(item.AssetUrl) builder.WriteString(assetUrl)
builder.WriteString(`",`)
builder.WriteString(`"thumbnail_url":"`)
builder.WriteString(thumbnailUrl)
builder.WriteString(`",`) builder.WriteString(`",`)
builder.WriteString(`"discord_message_url":"`) builder.WriteString(`"discord_message_url":"`)

View File

@ -46,7 +46,7 @@
<entry> <entry>
<title>New showcase item by {{ .OwnerName }}</title> <title>New showcase item by {{ .OwnerName }}</title>
<link rel="alternative" type="text/html" href="{{ .Url }}" /> <link rel="alternative" type="text/html" href="{{ .Url }}" />
<id>{{ .UUID }}</id> <id>{{ string2uuid .Url }}</id>
<published>{{ rfc3339 .Date }}</published> <published>{{ rfc3339 .Date }}</published>
<author> <author>
<name>{{ .OwnerName }}</name> <name>{{ .OwnerName }}</name>
@ -54,13 +54,19 @@
</author> </author>
<content type="xhtml"> <content type="xhtml">
<div xmlns="http://www.w3.org/1999/xhtml"> <div xmlns="http://www.w3.org/1999/xhtml">
{{ .Description }} <div>
{{ if snippetimage . }} {{ .Description }}
<img src="{{ .AssetUrl }}"/> </div>
{{ else if snippetvideo . }} {{ range .EmbedMedia }}
<video src="{{ .AssetUrl }}" controls="true"/> <div>
{{ else if snippetaudio . }} {{ if eq .Type mediaimage }}
<audio src="{{ .AssetUrl }}" controls="true"/> <img src="{{ .AssetUrl }}"/>
{{ else if eq .Type mediavideo }}
<video src="{{ .AssetUrl }}" controls="true"/>
{{ else if eq .Type mediaaudio }}
<audio src="{{ .AssetUrl }}" controls="true"/>
{{ end }}
</div>
{{ end }} {{ end }}
</div> </div>
</content> </content>

View File

@ -1,5 +1,31 @@
{{ if timelinepostitem . }} {{/*
<div class="timeline-item flex pa3 mb2 br3 {{ .Class }}"> TODO: The logic in here for how to present various types of timeline items needs to be more robust.
*/}}
{{ if or .Description .EmbedMedia }}
<div class="timeline-item flex flex-column pa3 mb2 br3" data-filter-title="{{ .FilterTitle }}">
<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>
<div class="mb2">{{ .Description }}</div>
{{ range .EmbedMedia }}
<div class="timeline-content-box {{ if eq .Type mediaembed }}embed{{ end }}">
{{ if eq .Type mediaimage }}
<img src="{{ .AssetUrl }}">
{{ else if eq .Type mediavideo }}
<video src="{{ .AssetUrl }}" preload="metadata" controls>
{{ else if eq .Type mediaaudio }}
<audio src="{{ .AssetUrl }}" controls>
{{ else if eq .Type mediaembed }}
{{ .EmbedHTML }}
{{ end }}
</div>
{{ end }}
</div>
{{ else }}
<div class="timeline-item flex pa3 mb2 br3" data-filter-title="{{ .FilterTitle }}">
<img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/> <img class="avatar-icon big lite mr3" src="{{ .OwnerAvatarUrl }}"/>
<div class="timeline-info overflow-hidden"> <div class="timeline-info overflow-hidden">
{{ template "breadcrumbs.html" .Breadcrumbs }} {{ template "breadcrumbs.html" .Breadcrumbs }}
@ -9,25 +35,5 @@
</div> </div>
</div> </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 }}

View File

@ -10,17 +10,19 @@
</div> </div>
<p class="mb2">{{ .Snippet.Description }}</p> <p class="mb2">{{ .Snippet.Description }}</p>
<div> <div>
{{ if snippetimage .Snippet }} {{ range .Snippet.EmbedMedia }}
<img src="{{ .Snippet.AssetUrl }}" /> {{ if eq .Type mediaimage }}
{{ else if snippetvideo .Snippet }} <img src="{{ .AssetUrl }}">
<video src="{{ .Snippet.AssetUrl }}" preload="metadata" controls /> {{ else if eq .Type mediavideo }}
{{ else if snippetaudio .Snippet }} <video src="{{ .AssetUrl }}" preload="metadata" controls>
<audio src="{{ .Snippet.AssetUrl }}" controls /> {{ else if eq .Type mediaaudio }}
{{ else if snippetyoutube .Snippet }} <audio src="{{ .AssetUrl }}" controls>
<div class="mb3 aspect-ratio aspect-ratio--16x9"> {{ else if eq .Type mediaembed }}
<iframe class="aspect-ratio--object" src="https://www.youtube-nocookie.com/embed/{{ .Snippet.YoutubeID }}" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0" /> <div class="mb3 aspect-ratio aspect-ratio--16x9">
</div> {{ .EmbedHTML }}
{{ end }} </div>
{{ end }}
{{ end }}
</div> </div>
</div> </div>
</div> </div>

View File

@ -6,19 +6,18 @@
{{ with .ProfileUserProjects }} {{ with .ProfileUserProjects }}
<div class="content-block ph3 ph0-ns"> <div class="content-block ph3 ph0-ns">
<h2>Projects</h2> <h2>Projects</h2>
<div class="ph3"> {{ range . }}
{{ range . }} <div class="mv3">
<div class="mv3"> {{ template "project_card.html" projectcarddata . "" }}
{{ template "project_card.html" projectcarddata . "" }} </div>
</div> {{ end }}
{{ end }}
</div>
</div> </div>
{{ end }} {{ end }}
{{ if gt (len .TimelineItems) 0 }} {{ if gt (len .TimelineItems) 0 }}
<div class="content-block timeline-container ph3 ph0-ns"> <div class="content-block timeline-container ph3 ph0-ns">
<h2>Recent Activity</h2> <h2>Recent Activity</h2>
<div class="timeline-filters mb2"> <div class="timeline-filters mb2">
{{/*
{{ if gt .NumForums 0 }} {{ 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> <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 }} {{ end }}
@ -28,6 +27,7 @@
{{ if gt .NumSnippets 0 }} {{ 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> <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 }} {{ end }}
*/}}
</div> </div>
<div class="timeline"> <div class="timeline">
{{ range .TimelineItems }} {{ range .TimelineItems }}
@ -53,11 +53,6 @@
<div class="value">{{ absoluteshortdate .ProfileUser.DateJoined }}</div> <div class="value">{{ absoluteshortdate .ProfileUser.DateJoined }}</div>
</div> </div>
<div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Posts</div>
<div class="value">{{ add .NumForums .NumBlogs }}</div>
</div>
{{ if .ProfileUser.Email }} {{ if .ProfileUser.Email }}
<div class="pair flex flex-wrap"> <div class="pair flex flex-wrap">
<div class="key flex-auto mr1">Email</div> <div class="key flex-auto mr1">Email</div>
@ -75,15 +70,50 @@
</div> </div>
</div> </div>
<script> <script>
let timelineList = document.querySelector(".timeline"); const filterTitles = [];
let toggles = document.querySelectorAll(".timeline-filters .filter input"); for (const item of document.querySelectorAll('.timeline-item')) {
function timelineFilterToggle(ev) { const title = item.getAttribute('data-filter-title');
timelineList.classList.toggle("no-" + ev.target.getAttribute("data-type"), !ev.target.checked); if (title && !filterTitles.includes(title)) {
filterTitles.push(title);
}
} }
for (let i = 0; i < toggles.length; ++i) { filterTitles.sort();
let toggle = toggles[i];
toggle.checked = true; function itemsForFilterTitle(title) {
toggle.addEventListener("change", timelineFilterToggle); return document.querySelectorAll(`.timeline-item[data-filter-title="${title}"]`);
}
const filters = document.querySelector('.timeline-filters');
for (const title of filterTitles) {
const container = document.createElement("div");
container.className = "dib filter mr2";
const id = `timeline-checkbox-${title.replaceAll(/\s/g, '-')}`;
const input = document.createElement("input");
input.className = "v-mid mr1";
input.type = "checkbox";
input.id = id;
input.checked = true;
input.addEventListener("change", e => {
for (const item of itemsForFilterTitle(title)) {
if (e.target.checked) {
item.style.removeProperty("display");
} else {
item.style.setProperty("display", "none")
}
}
});
container.appendChild(input);
const label = document.createElement("label");
label.className = "v-mid";
label.htmlFor = id;
label.innerText = `${title} (${itemsForFilterTitle(title).length})`;
container.appendChild(label);
filters.appendChild(container);
} }
</script> </script>
{{ end }} {{ end }}

View File

@ -11,6 +11,7 @@ import (
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging" "git.handmade.network/hmn/hmn/src/logging"
"github.com/Masterminds/sprig" "github.com/Masterminds/sprig"
"github.com/google/uuid"
"github.com/teacat/noire" "github.com/teacat/noire"
) )
@ -179,6 +180,9 @@ var HMNTemplateFuncs = template.FuncMap{
"staticthemenobust": func(theme string, filepath string) string { "staticthemenobust": func(theme string, filepath string) string {
return hmnurl.BuildTheme(filepath, theme, false) return hmnurl.BuildTheme(filepath, theme, false)
}, },
"string2uuid": func(s string) string {
return uuid.NewSHA1(uuid.NameSpaceURL, []byte(s)).URN()
},
"timehtml": func(formatted string, t time.Time) template.HTML { "timehtml": func(formatted string, t time.Time) template.HTML {
iso := t.UTC().Format(time.RFC3339) iso := t.UTC().Format(time.RFC3339)
return template.HTML(fmt.Sprintf(`<time datetime="%s">%s</time>`, iso, formatted)) return template.HTML(fmt.Sprintf(`<time datetime="%s">%s</time>`, iso, formatted))
@ -195,36 +199,8 @@ var HMNTemplateFuncs = template.FuncMap{
} }
}, },
"timelinepostitem": func(item TimelineItem) bool { "mediaimage": func() TimelineItemMediaType { return TimelineItemMediaTypeImage },
if item.Type == TimelineTypeForumThread || "mediavideo": func() TimelineItemMediaType { return TimelineItemMediaTypeVideo },
item.Type == TimelineTypeForumReply || "mediaaudio": func() TimelineItemMediaType { return TimelineItemMediaTypeAudio },
item.Type == TimelineTypeBlogPost || "mediaembed": func() TimelineItemMediaType { return TimelineItemMediaTypeEmbed },
item.Type == TimelineTypeBlogComment {
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
},
} }

View File

@ -244,45 +244,45 @@ type ThreadListItem struct {
Content string Content string
} }
type TimelineType int
const (
TimelineTypeUnknown TimelineType = iota
TimelineTypeForumThread
TimelineTypeForumReply
TimelineTypeBlogPost
TimelineTypeBlogComment
TimelineTypeSnippetImage
TimelineTypeSnippetVideo
TimelineTypeSnippetAudio
TimelineTypeSnippetYoutube
)
type TimelineItem struct { type TimelineItem struct {
Type TimelineType Date time.Time
TypeTitle string Title string
Class string TypeTitle string
Date time.Time FilterTitle string
Url string Breadcrumbs []Breadcrumb
UUID string Url string
DiscordMessageUrl string
OwnerAvatarUrl string OwnerAvatarUrl string
OwnerName string OwnerName string
OwnerUrl string OwnerUrl string
Description template.HTML
DiscordMessageUrl string Description template.HTML
Width int TruncateDescription bool
Height int
AssetUrl string
MimeType string
YoutubeID string
Title string PreviewMedia TimelineItemMedia
Breadcrumbs []Breadcrumb EmbedMedia []TimelineItemMedia
CanShowcase bool // whether this snippet can be shown in a showcase gallery
}
type TimelineItemMediaType int
const (
TimelineItemMediaTypeImage TimelineItemMediaType = iota + 1
TimelineItemMediaTypeVideo
TimelineItemMediaTypeAudio
TimelineItemMediaTypeEmbed
)
type TimelineItemMedia struct {
Type TimelineItemMediaType
AssetUrl string
EmbedHTML template.HTML
ThumbnailUrl string
MimeType string
Width, Height int
ExtraOpenGraphItems []OpenGraphItem
} }
type ProjectCardData struct { type ProjectCardData struct {

View File

@ -252,7 +252,6 @@ func AtomFeed(c *RequestContext) ResponseData {
for _, s := range snippetQuerySlice { for _, s := range snippetQuerySlice {
row := s.(*snippetQuery) row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
timelineItem.UUID = uuid.NewSHA1(uuid.NameSpaceURL, []byte(timelineItem.Url)).URN()
feedData.Snippets = append(feedData.Snippets, timelineItem) feedData.Snippets = append(feedData.Snippets, timelineItem)
} }
c.Perf.EndBlock() c.Perf.EndBlock()

View File

@ -49,7 +49,7 @@ func JamIndex(c *RequestContext) ResponseData {
for _, s := range snippetQuerySlice { for _, s := range snippetQuerySlice {
row := s.(*snippetQuery) row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
if timelineItem.Type != templates.TimelineTypeSnippetYoutube { if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem) showcaseItems = append(showcaseItems, timelineItem)
} }
} }

View File

@ -246,7 +246,7 @@ func Index(c *RequestContext) ResponseData {
for _, s := range snippetQuerySlice { for _, s := range snippetQuerySlice {
row := s.(*snippetQuery) row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
if timelineItem.Type != templates.TimelineTypeSnippetYoutube { if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem) showcaseItems = append(showcaseItems, timelineItem)
} }
} }

View File

@ -45,7 +45,7 @@ func Showcase(c *RequestContext) ResponseData {
for _, s := range snippetQuerySlice { for _, s := range snippetQuerySlice {
row := s.(*snippetQuery) row := s.(*snippetQuery)
timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme)
if timelineItem.Type != templates.TimelineTypeSnippetYoutube { if timelineItem.CanShowcase {
showcaseItems = append(showcaseItems, timelineItem) showcaseItems = append(showcaseItems, timelineItem)
} }
} }

View File

@ -70,37 +70,34 @@ func Snippet(c *RequestContext) ResponseData {
{Property: "og:description", Value: string(snippet.Description)}, {Property: "og:description", Value: string(snippet.Description)},
} }
if snippet.Type == templates.TimelineTypeSnippetImage { if len(snippet.EmbedMedia) > 0 {
opengraphImage := []templates.OpenGraphItem{ media := snippet.EmbedMedia[0]
{Property: "og:image", Value: snippet.AssetUrl},
{Property: "og:image:width", Value: strconv.Itoa(snippet.Width)}, switch media.Type {
{Property: "og:image:height", Value: strconv.Itoa(snippet.Height)}, case templates.TimelineItemMediaTypeImage:
{Property: "og:image:type", Value: snippet.MimeType}, opengraph = append(opengraph,
{Name: "twitter:card", Value: "summary_large_image"}, templates.OpenGraphItem{Property: "og:image", Value: media.AssetUrl},
templates.OpenGraphItem{Property: "og:image:width", Value: strconv.Itoa(media.Width)},
templates.OpenGraphItem{Property: "og:image:height", Value: strconv.Itoa(media.Height)},
templates.OpenGraphItem{Property: "og:image:type", Value: media.MimeType},
templates.OpenGraphItem{Name: "twitter:card", Value: "summary_large_image"},
)
case templates.TimelineItemMediaTypeVideo:
opengraph = append(opengraph,
templates.OpenGraphItem{Property: "og:video", Value: media.AssetUrl},
templates.OpenGraphItem{Property: "og:video:width", Value: strconv.Itoa(media.Width)},
templates.OpenGraphItem{Property: "og:video:height", Value: strconv.Itoa(media.Height)},
templates.OpenGraphItem{Property: "og:video:type", Value: media.MimeType},
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
)
case templates.TimelineItemMediaTypeAudio:
opengraph = append(opengraph,
templates.OpenGraphItem{Property: "og:audio", Value: media.AssetUrl},
templates.OpenGraphItem{Property: "og:audio:type", Value: media.MimeType},
templates.OpenGraphItem{Name: "twitter:card", Value: "player"},
)
} }
opengraph = append(opengraph, opengraphImage...) opengraph = append(opengraph, media.ExtraOpenGraphItems...)
} else if snippet.Type == templates.TimelineTypeSnippetVideo {
opengraphVideo := []templates.OpenGraphItem{
{Property: "og:video", Value: snippet.AssetUrl},
{Property: "og:video:width", Value: strconv.Itoa(snippet.Width)},
{Property: "og:video:height", Value: strconv.Itoa(snippet.Height)},
{Property: "og:video:type", Value: snippet.MimeType},
{Name: "twitter:card", Value: "player"},
}
opengraph = append(opengraph, opengraphVideo...)
} else if snippet.Type == templates.TimelineTypeSnippetAudio {
opengraphAudio := []templates.OpenGraphItem{
{Property: "og:audio", Value: snippet.AssetUrl},
{Property: "og:audio:type", Value: snippet.MimeType},
{Name: "twitter:card", Value: "player"},
}
opengraph = append(opengraph, opengraphAudio...)
} else if snippet.Type == templates.TimelineTypeSnippetYoutube {
opengraphYoutube := []templates.OpenGraphItem{
{Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", snippet.YoutubeID)},
{Name: "twitter:card", Value: "player"},
}
opengraph = append(opengraph, opengraphYoutube...)
} }
baseData := getBaseData( baseData := getBaseData(

View File

@ -1,136 +1,174 @@
package website package website
import ( import (
"fmt"
"html/template" "html/template"
"regexp" "regexp"
"strings" "strings"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/logging"
"git.handmade.network/hmn/hmn/src/models" "git.handmade.network/hmn/hmn/src/models"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
) )
var TimelineTypeMap = map[models.ThreadType][]templates.TimelineType{ type TimelineTypeTitles struct {
// { First post , Subsequent post } TypeTitleFirst string
models.ThreadTypeProjectBlogPost: {templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment}, TypeTitleNotFirst string
models.ThreadTypeForumPost: {templates.TimelineTypeForumThread, templates.TimelineTypeForumReply}, FilterTitle string
} }
var TimelineItemClassMap = map[templates.TimelineType]string{ var TimelineTypeTitleMap = map[models.ThreadType]TimelineTypeTitles{
templates.TimelineTypeUnknown: "", models.ThreadTypeProjectBlogPost: {"New blog post", "Blog comment", "Blogs"},
models.ThreadTypeForumPost: {"New forum thread", "Forum reply", "Forums"},
templates.TimelineTypeForumThread: "forums",
templates.TimelineTypeForumReply: "forums",
templates.TimelineTypeBlogPost: "blogs",
templates.TimelineTypeBlogComment: "blogs",
templates.TimelineTypeSnippetImage: "snippets",
templates.TimelineTypeSnippetVideo: "snippets",
templates.TimelineTypeSnippetAudio: "snippets",
templates.TimelineTypeSnippetYoutube: "snippets",
} }
var TimelineTypeTitleMap = map[templates.TimelineType]string{ func PostToTimelineItem(
templates.TimelineTypeUnknown: "", lineageBuilder *models.SubforumLineageBuilder,
post *models.Post,
templates.TimelineTypeForumThread: "New forum thread", thread *models.Thread,
templates.TimelineTypeForumReply: "Forum reply", project *models.Project,
owner *models.User,
templates.TimelineTypeBlogPost: "New blog post", currentTheme string,
templates.TimelineTypeBlogComment: "Blog comment", ) templates.TimelineItem {
item := templates.TimelineItem{
templates.TimelineTypeSnippetImage: "Snippet", Date: post.PostDate,
templates.TimelineTypeSnippetVideo: "Snippet", Title: thread.Title,
templates.TimelineTypeSnippetAudio: "Snippet", Breadcrumbs: GenericThreadBreadcrumbs(lineageBuilder, project, thread),
templates.TimelineTypeSnippetYoutube: "Snippet", Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
}
func PostToTimelineItem(lineageBuilder *models.SubforumLineageBuilder, post *models.Post, thread *models.Thread, project *models.Project, owner *models.User, currentTheme string) templates.TimelineItem {
itemType := templates.TimelineTypeUnknown
typeByCatKind, found := TimelineTypeMap[post.ThreadType]
if found {
isNotFirst := 0
if thread.FirstID != post.ID {
isNotFirst = 1
}
itemType = typeByCatKind[isNotFirst]
}
return templates.TimelineItem{
Type: itemType,
TypeTitle: TimelineTypeTitleMap[itemType],
Class: TimelineItemClassMap[itemType],
Date: post.PostDate,
Url: UrlForGenericPost(thread, post, lineageBuilder, project.Slug),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
OwnerName: owner.BestName(), OwnerName: owner.BestName(),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username), OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
Description: "", // NOTE(asaf): No description for posts
Title: thread.Title,
Breadcrumbs: GenericThreadBreadcrumbs(lineageBuilder, project, thread),
} }
if typeTitles, ok := TimelineTypeTitleMap[post.ThreadType]; ok {
if thread.FirstID == post.ID {
item.TypeTitle = typeTitles.TypeTitleFirst
} else {
item.TypeTitle = typeTitles.TypeTitleNotFirst
}
item.FilterTitle = typeTitles.FilterTitle
} else {
logging.Warn().
Int("postID", post.ID).
Int("threadType", int(post.ThreadType)).
Msg("unknown thread type for post")
}
return item
} }
var YoutubeRegex = regexp.MustCompile(`(?i)youtube\.com/watch\?.*v=(?P<videoid>[^/&]+)`) func SnippetToTimelineItem(
var YoutubeShortRegex = regexp.MustCompile(`(?i)youtu\.be/(?P<videoid>[^/]+)`) snippet *models.Snippet,
asset *models.Asset,
discordMessage *models.DiscordMessage,
owner *models.User,
currentTheme string,
) templates.TimelineItem {
item := templates.TimelineItem{
Date: snippet.When,
FilterTitle: "Snippets",
Url: hmnurl.BuildSnippet(snippet.ID),
func SnippetToTimelineItem(snippet *models.Snippet, asset *models.Asset, discordMessage *models.DiscordMessage, owner *models.User, currentTheme string) templates.TimelineItem { OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme),
itemType := templates.TimelineTypeUnknown OwnerName: owner.BestName(),
youtubeId := "" OwnerUrl: hmnurl.BuildUserProfile(owner.Username),
assetUrl := ""
mimeType := ""
width := 0
height := 0
discordMessageUrl := ""
if asset == nil { Description: template.HTML(snippet.DescriptionHtml),
match := YoutubeRegex.FindStringSubmatch(*snippet.Url)
index := YoutubeRegex.SubexpIndex("videoid") CanShowcase: true,
if match == nil { }
match = YoutubeShortRegex.FindStringSubmatch(*snippet.Url)
index = YoutubeShortRegex.SubexpIndex("videoid") if asset != nil {
}
if match != nil {
youtubeId = match[index]
itemType = templates.TimelineTypeSnippetYoutube
}
} else {
if strings.HasPrefix(asset.MimeType, "image/") { if strings.HasPrefix(asset.MimeType, "image/") {
itemType = templates.TimelineTypeSnippetImage item.EmbedMedia = append(item.EmbedMedia, imageMediaItem(asset))
} else if strings.HasPrefix(asset.MimeType, "video/") { } else if strings.HasPrefix(asset.MimeType, "video/") {
itemType = templates.TimelineTypeSnippetVideo item.EmbedMedia = append(item.EmbedMedia, videoMediaItem(asset))
} else if strings.HasPrefix(asset.MimeType, "audio/") { } else if strings.HasPrefix(asset.MimeType, "audio/") {
itemType = templates.TimelineTypeSnippetAudio item.EmbedMedia = append(item.EmbedMedia, audioMediaItem(asset))
}
}
if snippet.Url != nil {
url := *snippet.Url
if videoId := getYoutubeVideoID(url); videoId != "" {
item.EmbedMedia = append(item.EmbedMedia, youtubeMediaItem(videoId))
item.CanShowcase = false
} }
assetUrl = hmnurl.BuildS3Asset(asset.S3Key)
mimeType = asset.MimeType
width = asset.Width
height = asset.Height
} }
if discordMessage != nil { if discordMessage != nil {
discordMessageUrl = discordMessage.Url item.DiscordMessageUrl = discordMessage.Url
} }
return templates.TimelineItem{ return item
Type: itemType, }
Class: TimelineItemClassMap[itemType],
Date: snippet.When,
Url: hmnurl.BuildSnippet(snippet.ID),
OwnerAvatarUrl: templates.UserAvatarUrl(owner, currentTheme), var youtubeRegexes = [...]*regexp.Regexp{
OwnerName: owner.BestName(), regexp.MustCompile(`(?i)youtube\.com/watch\?.*v=(?P<videoid>[^/&]+)`),
OwnerUrl: hmnurl.BuildUserProfile(owner.Username), regexp.MustCompile(`(?i)youtu\.be/(?P<videoid>[^/]+)`),
Description: template.HTML(snippet.DescriptionHtml), }
DiscordMessageUrl: discordMessageUrl, func getYoutubeVideoID(url string) string {
Width: width, for _, regex := range youtubeRegexes {
Height: height, match := regex.FindStringSubmatch(url)
AssetUrl: assetUrl, if match != nil {
MimeType: mimeType, return match[regex.SubexpIndex("videoid")]
YoutubeID: youtubeId, }
}
return ""
}
func imageMediaItem(asset *models.Asset) templates.TimelineItemMedia {
assetUrl := hmnurl.BuildS3Asset(asset.S3Key)
return templates.TimelineItemMedia{
Type: templates.TimelineItemMediaTypeImage,
AssetUrl: assetUrl,
ThumbnailUrl: assetUrl, // TODO: Use smaller thumbnails?
MimeType: asset.MimeType,
Width: asset.Width,
Height: asset.Height,
}
}
func videoMediaItem(asset *models.Asset) templates.TimelineItemMedia {
assetUrl := hmnurl.BuildS3Asset(asset.S3Key)
return templates.TimelineItemMedia{
Type: templates.TimelineItemMediaTypeVideo,
AssetUrl: assetUrl,
// TODO: Use image thumbnails
MimeType: asset.MimeType,
Width: asset.Width,
Height: asset.Height,
}
}
func audioMediaItem(asset *models.Asset) templates.TimelineItemMedia {
assetUrl := hmnurl.BuildS3Asset(asset.S3Key)
return templates.TimelineItemMedia{
Type: templates.TimelineItemMediaTypeAudio,
AssetUrl: assetUrl,
MimeType: asset.MimeType,
Width: asset.Width,
Height: asset.Height,
}
}
func youtubeMediaItem(videoId string) templates.TimelineItemMedia {
return templates.TimelineItemMedia{
Type: templates.TimelineItemMediaTypeEmbed,
EmbedHTML: template.HTML(fmt.Sprintf(
`<iframe src="https://www.youtube-nocookie.com/embed/%s" allow="accelerometer; encrypted-media; gyroscope;" allowfullscreen frameborder="0"></iframe>`,
template.HTMLEscapeString(videoId),
)),
ExtraOpenGraphItems: []templates.OpenGraphItem{
{Property: "og:video", Value: fmt.Sprintf("https://youtube.com/watch?v=%s", videoId)},
{Name: "twitter:card", Value: "player"},
},
} }
} }

View File

@ -26,9 +26,6 @@ type UserProfileTemplateData struct {
ProfileUserLinks []templates.Link ProfileUserLinks []templates.Link
ProfileUserProjects []templates.Project ProfileUserProjects []templates.Project
TimelineItems []templates.TimelineItem TimelineItems []templates.TimelineItem
NumForums int
NumBlogs int
NumSnippets int
} }
func UserProfile(c *RequestContext) ResponseData { func UserProfile(c *RequestContext) ResponseData {
@ -166,42 +163,27 @@ func UserProfile(c *RequestContext) ResponseData {
c.Perf.StartBlock("PROFILE", "Construct timeline items") c.Perf.StartBlock("PROFILE", "Construct timeline items")
timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippetQuerySlice)) timelineItems := make([]templates.TimelineItem, 0, len(posts)+len(snippetQuerySlice))
numForums := 0
numBlogs := 0
numSnippets := len(snippetQuerySlice)
for _, post := range posts { for _, post := range posts {
timelineItem := PostToTimelineItem( timelineItems = append(timelineItems, PostToTimelineItem(
lineageBuilder, lineageBuilder,
&post.Post, &post.Post,
&post.Thread, &post.Thread,
&post.Project, &post.Project,
profileUser, profileUser,
c.Theme, c.Theme,
) ))
switch timelineItem.Type {
case templates.TimelineTypeForumThread, templates.TimelineTypeForumReply:
numForums += 1
case templates.TimelineTypeBlogPost, templates.TimelineTypeBlogComment:
numBlogs += 1
}
if timelineItem.Type != templates.TimelineTypeUnknown {
timelineItems = append(timelineItems, timelineItem)
} else {
c.Logger.Warn().Int("post ID", post.Post.ID).Msg("Unknown timeline item type for post")
}
} }
for _, snippetRow := range snippetQuerySlice { for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery) snippetData := snippetRow.(*snippetQuery)
timelineItem := SnippetToTimelineItem( timelineItems = append(timelineItems, SnippetToTimelineItem(
&snippetData.Snippet, &snippetData.Snippet,
snippetData.Asset, snippetData.Asset,
snippetData.DiscordMessage, snippetData.DiscordMessage,
profileUser, profileUser,
c.Theme, c.Theme,
) ))
timelineItems = append(timelineItems, timelineItem)
} }
c.Perf.StartBlock("PROFILE", "Sort timeline") c.Perf.StartBlock("PROFILE", "Sort timeline")
@ -223,9 +205,6 @@ func UserProfile(c *RequestContext) ResponseData {
ProfileUserLinks: profileUserLinks, ProfileUserLinks: profileUserLinks,
ProfileUserProjects: templateProjects, ProfileUserProjects: templateProjects,
TimelineItems: timelineItems, TimelineItems: timelineItems,
NumForums: numForums,
NumBlogs: numBlogs,
NumSnippets: numSnippets,
}, c.Perf) }, c.Perf)
return res return res
} }