From 8aa4554934de3524190e8023280a4afabc2d0771 Mon Sep 17 00:00:00 2001 From: Asaf Gartner Date: Tue, 22 Jun 2021 20:08:05 +0300 Subject: [PATCH] Showcase page --- public/close.svg | 11 ++ public/js/showcase.js | 124 +++++++++++++ public/js/templates.js | 68 +++++++ src/templates/mapping.go | 68 +++++++ .../src/include/showcase_templates.html | 38 ++++ src/templates/src/showcase.html | 171 ++++++++++++++++++ src/website/routes.go | 1 + src/website/showcase.go | 68 +++++++ 8 files changed, 549 insertions(+) create mode 100644 public/close.svg create mode 100644 public/js/showcase.js create mode 100644 public/js/templates.js create mode 100644 src/templates/src/include/showcase_templates.html create mode 100644 src/templates/src/showcase.html create mode 100644 src/website/showcase.go diff --git a/public/close.svg b/public/close.svg new file mode 100644 index 00000000..a1a36cf9 --- /dev/null +++ b/public/close.svg @@ -0,0 +1,11 @@ + + + + + + + diff --git a/public/js/showcase.js b/public/js/showcase.js new file mode 100644 index 00000000..fad63692 --- /dev/null +++ b/public/js/showcase.js @@ -0,0 +1,124 @@ +const TimelineTypes = { + UNKNOWN: 0, + FORUM_THREAD: 1, + FORUM_REPLY: 2, + BLOG_POST: 3, + BLOG_COMMENT: 4, + WIKI_CREATE: 5, + WIKI_EDIT: 6, + WIKI_TALK: 7, + LIBRARY_COMMENT: 8, + SNIPPET_IMAGE: 9, + SNIPPET_VIDEO: 10, + SNIPPET_AUDIO: 11, + SNIPPET_YOUTUBE: 12 +}; + +const showcaseItemTemplate = makeTemplateCloner("showcase_item"); +const modalTemplate = makeTemplateCloner("timeline_modal"); + +function showcaseTimestamp(rawDate) { + const date = new Date(rawDate*1000); + return date.toLocaleDateString([], { 'dateStyle': 'long' }); +} + +function doOnce(f) { + let did = false; + return () => { + if (!did) { + f(); + did = true; + } + } +} + +function makeShowcaseItem(timelineItem) { + const timestamp = showcaseTimestamp(timelineItem.date); + + const itemEl = showcaseItemTemplate(); + itemEl.avatar.style.backgroundImage = `url('${timelineItem.owner_avatar}')`; + itemEl.username.textContent = timelineItem.owner_name; + itemEl.when.textContent = timestamp; + + let addThumbnailFunc = () => {}; + let createModalContentFunc = () => {}; + + switch (timelineItem.type) { + case TimelineTypes.SNIPPET_IMAGE: + addThumbnailFunc = () => { + itemEl.thumbnail.style.backgroundImage = `url('${timelineItem.asset_url}')`; + }; + + createModalContentFunc = () => { + const modalImage = document.createElement('img'); + modalImage.src = timelineItem.asset_url; + modalImage.classList.add('mw-100', 'mh-60vh'); + return modalImage; + }; + + break; + case TimelineTypes.SNIPPET_VIDEO: + addThumbnailFunc = () => { + const video = document.createElement('video'); + video.src = timelineItem.asset_url; + video.controls = false; + video.classList.add('h-100'); + video.preload = 'metadata'; + itemEl.thumbnail.appendChild(video); + }; + + createModalContentFunc = () => { + const modalVideo = document.createElement('video'); + modalVideo.src = timelineItem.asset_url; + modalVideo.controls = true; + modalVideo.preload = 'metadata'; + modalVideo.classList.add('mw-100', 'mh-60vh'); + return modalVideo; + }; + + break; + case TimelineTypes.SNIPPET_AUDIO: + createModalContentFunc = () => { + const modalAudio = document.createElement('audio'); + modalAudio.src = timelineItem.asset_url; + modalAudio.controls = true; + modalAudio.preload = 'metadata'; + modalAudio.classList.add('w-70'); + return modalAudio; + }; + + break; + // TODO(ben): Other snippet types? + } + + let modalEl = null; + itemEl.container.addEventListener('click', function() { + if (!modalEl) { + modalEl = modalTemplate(); + modalEl.description.innerHTML = timelineItem.description; + modalEl.asset_container.appendChild(createModalContentFunc()); + + modalEl.avatar.src = timelineItem.owner_avatar; + modalEl.userLink.textContent = timelineItem.owner_name; + modalEl.userLink.href = timelineItem.owner_url; + modalEl.date.textContent = timestamp; + modalEl.date.setAttribute("href", timelineItem.snippet_url); + + modalEl.discord_link.href = timelineItem.discord_message_url; + + function close() { + modalEl.overlay.remove(); + } + modalEl.overlay.addEventListener('click', close); + modalEl.close.addEventListener('click', close); + modalEl.container.addEventListener('click', function(e) { + e.stopPropagation(); + }); + } + + document.body.appendChild(modalEl.overlay); + }); + + return [itemEl, doOnce(addThumbnailFunc)]; +} + diff --git a/public/js/templates.js b/public/js/templates.js new file mode 100644 index 00000000..07d426a0 --- /dev/null +++ b/public/js/templates.js @@ -0,0 +1,68 @@ +var domLookupCache = {}; +var templatePathCache = {}; + +function getTemplateEl(id) { + if (!domLookupCache[id]) { + domLookupCache[id] = document.getElementById(id); + } + return domLookupCache[id]; +} + +function collectElements(paths, rootElement) { + var result = {}; + for (var i = 0; i < paths.length; ++i) { + var path = paths[i]; + var current = rootElement; + for (var j = 0; j < path[1].length; ++j) { + current = current.children[path[1][j]]; + } + result[path[0]] = current; + } + return result; +} + +function getTemplatePaths(id, rootElement) { + if (!templatePathCache[id]) { + var paths = []; + paths.push(["root", []]); + + function descend(path, el) { + for (var i = 0; i < el.children.length; ++i) { + var child = el.children[i]; + var childPath = path.concat([i]); + var tmplName = child.getAttribute("data-tmpl"); + if (tmplName) { + paths.push([tmplName, childPath]); + } + if (child.children.length > 0) { + descend(childPath, child); + } + } + } + + descend([], rootElement); + templatePathCache[id] = paths; + } + return templatePathCache[id]; +} + +function makeTemplateCloner(id) { + return function() { + var templateEl = getTemplateEl(id); + if (templateEl === null) { + throw new Error(`Couldn\'t find template with ID '${id}'`); + } + + var root = templateEl.content.cloneNode(true); + var paths = getTemplatePaths(id, root); + var result = collectElements(paths, root); + return result; + }; +} + +function emptyElement(el) { + var newEl = el.cloneNode(false); + el.parentElement.insertBefore(newEl, el); + el.parentElement.removeChild(el); + return newEl; +} diff --git a/src/templates/mapping.go b/src/templates/mapping.go index f1bcf125..7a329617 100644 --- a/src/templates/mapping.go +++ b/src/templates/mapping.go @@ -4,6 +4,8 @@ import ( "html/template" "net" "regexp" + "strconv" + "strings" "git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/models" @@ -223,6 +225,72 @@ func LinkToTemplate(link *models.Link) Link { } } +func TimelineItemsToJSON(items []TimelineItem) string { + // NOTE(asaf): As of 2021-06-22: This only serializes the data necessary for snippet showcase. + builder := strings.Builder{} + builder.WriteRune('[') + for i, item := range items { + if i > 0 { + builder.WriteRune(',') + } + builder.WriteRune('{') + + builder.WriteString(`"type":`) + builder.WriteString(strconv.Itoa(int(item.Type))) + builder.WriteRune(',') + + builder.WriteString(`"date":`) + builder.WriteString(strconv.FormatInt(item.Date.UTC().Unix(), 10)) + builder.WriteRune(',') + + builder.WriteString(`"description":"`) + jsonString := string(item.Description) + jsonString = strings.ReplaceAll(jsonString, `\`, `\\`) + jsonString = strings.ReplaceAll(jsonString, `"`, `\"`) + jsonString = strings.ReplaceAll(jsonString, "\n", "\\n") + jsonString = strings.ReplaceAll(jsonString, "\r", "\\r") + jsonString = strings.ReplaceAll(jsonString, "\t", "\\t") + builder.WriteString(jsonString) + builder.WriteString(`",`) + + builder.WriteString(`"owner_name":"`) + builder.WriteString(item.OwnerName) + builder.WriteString(`",`) + + builder.WriteString(`"owner_avatar":"`) + builder.WriteString(item.OwnerAvatarUrl) + builder.WriteString(`",`) + + builder.WriteString(`"owner_url":"`) + builder.WriteString(item.OwnerUrl) + builder.WriteString(`",`) + + builder.WriteString(`"snippet_url":"`) + builder.WriteString(item.Url) + builder.WriteString(`",`) + + builder.WriteString(`"width":`) + builder.WriteString(strconv.Itoa(item.Width)) + builder.WriteRune(',') + + builder.WriteString(`"height":`) + builder.WriteString(strconv.Itoa(item.Height)) + builder.WriteRune(',') + + builder.WriteString(`"asset_url":"`) + builder.WriteString(item.AssetUrl) + builder.WriteString(`",`) + + builder.WriteString(`"discord_message_url":"`) + builder.WriteString(item.DiscordMessageUrl) + builder.WriteString(`"`) + + builder.WriteRune('}') + } + builder.WriteRune(']') + return builder.String() +} + func maybeString(s *string) string { if s == nil { return "" diff --git a/src/templates/src/include/showcase_templates.html b/src/templates/src/include/showcase_templates.html new file mode 100644 index 00000000..20ee1b6d --- /dev/null +++ b/src/templates/src/include/showcase_templates.html @@ -0,0 +1,38 @@ + + + diff --git a/src/templates/src/showcase.html b/src/templates/src/showcase.html new file mode 100644 index 00000000..5c8522e6 --- /dev/null +++ b/src/templates/src/showcase.html @@ -0,0 +1,171 @@ +{{ template "base.html" . }} + +{{ define "extrahead" }} + + +{{ end }} + +{{ define "content" }} +
+
+
+
+

Community Showcase

+ +
+
+
+
+
+ + {{ template "showcase_templates.html" }} + + + + +{{ end }} diff --git a/src/website/routes.go b/src/website/routes.go index ffe3d880..b5a6de44 100644 --- a/src/website/routes.go +++ b/src/website/routes.go @@ -107,6 +107,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt // NOTE(asaf): HMN-only routes: mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) + mainRoutes.GET(hmnurl.RegexShowcase, Showcase) mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) diff --git a/src/website/showcase.go b/src/website/showcase.go new file mode 100644 index 00000000..c531b5e4 --- /dev/null +++ b/src/website/showcase.go @@ -0,0 +1,68 @@ +package website + +import ( + "net/http" + + "git.handmade.network/hmn/hmn/src/db" + "git.handmade.network/hmn/hmn/src/hmnurl" + "git.handmade.network/hmn/hmn/src/models" + "git.handmade.network/hmn/hmn/src/oops" + "git.handmade.network/hmn/hmn/src/templates" +) + +type ShowcaseData struct { + templates.BaseData + ShowcaseItems string // NOTE(asaf): JSON string + ShowcaseAtomFeedUrl string +} + +func Showcase(c *RequestContext) ResponseData { + c.Perf.StartBlock("SQL", "Fetch showcase snippets") + type snippetQuery struct { + Owner models.User `db:"owner"` + Snippet models.Snippet `db:"snippet"` + Asset *models.Asset `db:"asset"` + DiscordMessage *models.DiscordMessage `db:"discord_message"` + } + snippetQueryResult, err := db.Query(c.Context(), c.Conn, snippetQuery{}, + ` + SELECT $columns + FROM + handmade_snippet AS snippet + INNER JOIN auth_user AS owner ON owner.id = snippet.owner_id + 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 + ORDER BY snippet.when DESC + `, + ) + if err != nil { + return ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch snippets")) + } + snippetQuerySlice := snippetQueryResult.ToSlice() + showcaseItems := make([]templates.TimelineItem, 0, len(snippetQuerySlice)) + for _, s := range snippetQuerySlice { + row := s.(*snippetQuery) + timelineItem := SnippetToTimelineItem(&row.Snippet, row.Asset, row.DiscordMessage, &row.Owner, c.Theme) + if timelineItem.Type != templates.TimelineTypeSnippetYoutube { + showcaseItems = append(showcaseItems, timelineItem) + } + } + c.Perf.EndBlock() + + c.Perf.StartBlock("SHOWCASE", "Convert to json") + jsonItems := templates.TimelineItemsToJSON(showcaseItems) + c.Perf.EndBlock() + + baseData := getBaseData(c) + baseData.Title = "Community Showcase" + var res ResponseData + err = res.WriteTemplate("showcase.html", ShowcaseData{ + BaseData: baseData, + ShowcaseItems: jsonItems, + ShowcaseAtomFeedUrl: hmnurl.BuildAtomFeedForShowcase(), + }, c.Perf) + if err != nil { + panic(err) + } + return res +}