Showcase page
This commit is contained in:
		
							parent
							
								
									77273cdb33
								
							
						
					
					
						commit
						8aa4554934
					
				| 
						 | 
					@ -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  | 
| 
						 | 
					@ -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)];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -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 ""
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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>
 | 
				
			||||||
| 
						 | 
					@ -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 }}
 | 
				
			||||||
| 
						 | 
					@ -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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -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
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
		Reference in New Issue