Showcase page

This commit is contained in:
Asaf Gartner 2021-06-22 20:08:05 +03:00
parent 77273cdb33
commit 8aa4554934
8 changed files with 549 additions and 0 deletions

11
public/close.svg Normal file
View File

@ -0,0 +1,11 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 47.971 47.971">
<g>
<path d="M28.228,23.986L47.092,5.122c1.172-1.171,1.172-3.071,0-4.242c-1.172-1.172-3.07-1.172-4.242,0L23.986,19.744L5.121,0.88
c-1.172-1.172-3.07-1.172-4.242,0c-1.172,1.171-1.172,3.071,0,4.242l18.865,18.864L0.879,42.85c-1.172,1.171-1.172,3.071,0,4.242
C1.465,47.677,2.233,47.97,3,47.97s1.535-0.293,2.121-0.879l18.865-18.864L42.85,47.091c0.586,0.586,1.354,0.879,2.121,0.879
s1.535-0.293,2.121-0.879c1.172-1.171,1.172-3.071,0-4.242L28.228,23.986z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 776 B

124
public/js/showcase.js Normal file
View File

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

68
public/js/templates.js Normal file
View File

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

View File

@ -4,6 +4,8 @@ import (
"html/template" "html/template"
"net" "net"
"regexp" "regexp"
"strconv"
"strings"
"git.handmade.network/hmn/hmn/src/hmnurl" "git.handmade.network/hmn/hmn/src/hmnurl"
"git.handmade.network/hmn/hmn/src/models" "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 { func maybeString(s *string) string {
if s == nil { if s == nil {
return "" return ""

View File

@ -0,0 +1,38 @@
<template id="showcase_item">
<div data-tmpl="container" class="showcase-item ba b--theme flex-shrink-0 bg-dark-gray hide-child relative overflow-hidden pointer">
<div data-tmpl="thumbnail" class="absolute absolute--fill z-0 flex justify-start items-center bg-left cover"></div>
<div class="overlay absolute absolute--fill z-1 child">
<div class="gradient relative">
<div class="user-info flex pa2 white f7 lh-title items-center">
<div data-tmpl="avatar" class="br-100 w2 h2 cover flex-shrink-0"></div>
<div class="flex-grow-1 flex flex-column pl1">
<div data-tmpl="username">Unknown User</div>
<div data-tmpl="when" class="i f8">Unknown Time</div>
</div>
</div>
</div>
</div>
</div>
</template>
<template id="timeline_modal">
<div data-tmpl="overlay" class="timeline-modal fixed absolute--fill bg-black-80 z-999 flex flex-column justify-center items-center">
<div data-tmpl="container" class="container timeline-item relative flex-shrink-0 shadow-1 flex flex-column items-stretch w-100 mh0 mh3-ns br2-ns overflow-hidden">
<div data-tmpl="asset_container" class="bg-dark-gray flex justify-center"></div>
<div class="bg--content pa3 overflow-y-auto">
<div class="timeline-user-info mb2 flex items-center">
<img class="avatar-icon lite mr2" data-tmpl="avatar"/>
<a class="user" data-tmpl="userLink"></a>
<a data-tmpl="date" class="datetime tr" style="flex: 1 1 auto;"></a>
</div>
<div data-tmpl="description">
Unknown description
</div>
<div class="i f7 pt2">
<a data-tmpl="discord_link" target="_blank">View original message on Discord</a>
</div>
</div>
<div data-tmpl="close" class="absolute right-0 top-0 w2 h2 flex justify-center items-center bg-black-80 white br-100 ma1 pointer svgicon svgicon-nofix"><img src="{{ static "close.svg" }}" /></div>
</div>
</div>
</template>

View File

@ -0,0 +1,171 @@
{{ template "base.html" . }}
{{ define "extrahead" }}
<script src="{{ static "js/templates.js" }}"></script>
<script src="{{ static "js/showcase.js" }}"></script>
{{ end }}
{{ define "content" }}
<div class="content-block">
<div class="ph2 ph0-ns pb4">
<div class="optionbar">
<div class="tc tl-l w-100 pb2">
<h2 class="di-l mr2-l">Community Showcase</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{{ .ShowcaseAtomFeedUrl }}"><span class="icon big">4</span> Showcase Feed</a>
</li>
</ul>
</div>
</div>
<div id="showcase-container" class="mh2 mh0-ns pb4"></div>
</div>
</div>
{{ template "showcase_templates.html" }}
<template id="showcase-month">
<h3 data-tmpl="dateHeader" class="mt3 f4 fw5">Unknown Date</h3>
<div data-tmpl="itemsContainer" class="month-container"></div>
</template>
<script>
const ROW_HEIGHT = 300;
const ITEM_SPACING = 4;
const monthTemplate = makeTemplateCloner('showcase-month');
const showcaseItems = JSON.parse("{{ .ShowcaseItems }}");
const addThumbnailFuncs = new Array(showcaseItems.length);
let showcaseContainer = document.querySelector('#showcase-container');
const itemElementsByMonth = []; // array of arrays
let currentMonthElements = [];
let currentMonth = null;
let currentYear = null;
for (let i = 0; i < showcaseItems.length; i++) {
const item = showcaseItems[i];
const date = new Date(item.date * 1000); // TODO(asaf): Verify that this is still correct with our new JSON marshalling
if (date.getMonth() !== currentMonth || date.getFullYear() !== currentYear) {
if (currentMonthElements.length > 0) {
itemElementsByMonth.push(currentMonthElements);
}
currentMonthElements = [];
currentMonth = date.getMonth();
currentYear = date.getFullYear();
}
const [itemEl, addThumbnail] = makeShowcaseItem(item);
itemEl.container.setAttribute('data-index', i);
itemEl.container.setAttribute('data-date', item.date);
addThumbnailFuncs[i] = addThumbnail;
currentMonthElements.push(itemEl.container);
}
if (currentMonthElements.length > 0) {
itemElementsByMonth.push(currentMonthElements);
}
function layout() {
const width = showcaseContainer.getBoundingClientRect().width;
showcaseContainer = emptyElement(showcaseContainer);
function addRow(itemEls, rowWidth, container) {
const totalSpacing = ITEM_SPACING * (itemEls.length - 1);
const scaleFactor = (width / Math.max(rowWidth, width));
const row = document.createElement('div');
row.classList.add('flex');
row.classList.toggle('justify-between', rowWidth >= width);
row.style.marginBottom = `${ITEM_SPACING}px`;
for (const itemEl of itemEls) {
const index = parseInt(itemEl.getAttribute('data-index'), 10);
const item = showcaseItems[index];
const aspect = item.width / item.height;
const baseWidth = (aspect * ROW_HEIGHT) * scaleFactor;
const actualWidth = baseWidth - (totalSpacing / itemEls.length);
itemEl.style.width = `${actualWidth}px`;
itemEl.style.height = `${scaleFactor * ROW_HEIGHT}px`;
itemEl.style.marginRight = `${ITEM_SPACING}px`;
row.appendChild(itemEl);
}
container.appendChild(row);
}
for (const monthEls of itemElementsByMonth) {
const month = monthTemplate();
const firstDate = new Date(parseFloat(monthEls[0].getAttribute('data-date')) * 1000);
month.dateHeader.textContent = firstDate.toLocaleDateString([], { month: 'long', year: 'numeric' });
let rowItemEls = [];
let rowWidth = 0;
for (const itemEl of monthEls) {
const index = parseInt(itemEl.getAttribute('data-index'), 10);
const item = showcaseItems[index];
const aspect = item.width / item.height;
rowWidth += aspect * ROW_HEIGHT;
rowItemEls.push(itemEl);
if (rowWidth > width) {
addRow(rowItemEls, rowWidth, month.itemsContainer);
rowItemEls = [];
rowWidth = 0;
}
}
addRow(rowItemEls, rowWidth, month.itemsContainer);
showcaseContainer.appendChild(month.root);
}
}
function tryLoadImages() {
const OFFSCREEN_THRESHOLD = 0;
const months = document.querySelectorAll('.month-container');
for (const month of months) {
const rect = month.getBoundingClientRect();
const offscreen = (
rect.bottom < -OFFSCREEN_THRESHOLD
|| rect.top > window.innerHeight + OFFSCREEN_THRESHOLD
);
if (!offscreen) {
const items = month.querySelectorAll('.showcase-item');
for (const item of items) {
const i = parseInt(item.getAttribute('data-index'), 10);
addThumbnailFuncs[i]();
}
}
}
}
layout();
layout(); // scrollbars are fun!!
tryLoadImages();
window.addEventListener('resize', () => {
layout();
tryLoadImages();
});
window.addEventListener('scroll', () => {
tryLoadImages();
});
</script>
{{ end }}

View File

@ -107,6 +107,7 @@ func NewWebsiteRoutes(conn *pgxpool.Pool, perfCollector *perf.PerfCollector) htt
// NOTE(asaf): HMN-only routes: // NOTE(asaf): HMN-only routes:
mainRoutes.GET(hmnurl.RegexFeed, Feed) mainRoutes.GET(hmnurl.RegexFeed, Feed)
mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed) mainRoutes.GET(hmnurl.RegexAtomFeed, AtomFeed)
mainRoutes.GET(hmnurl.RegexShowcase, Showcase)
mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex) mainRoutes.GET(hmnurl.RegexProjectIndex, ProjectIndex)
mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile) mainRoutes.GET(hmnurl.RegexUserProfile, UserProfile)

68
src/website/showcase.go Normal file
View File

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