Rework the home page

This commit is contained in:
Ben Visness 2021-10-24 15:48:28 -05:00
parent 7d422cb533
commit f8d5f9fce5
18 changed files with 363 additions and 687 deletions

View File

@ -113,3 +113,106 @@ function makeShowcaseItem(timelineItem) {
return [itemEl, doOnce(addThumbnailFunc)]; return [itemEl, doOnce(addThumbnailFunc)];
} }
function initShowcaseContainer(container, items, rowHeight = 300, itemSpacing = 4) {
const addThumbnailFuncs = new Array(items.length);
const itemElements = []; // array of arrays
for (let i = 0; i < items.length; i++) {
const item = items[i];
const [itemEl, addThumbnail] = makeShowcaseItem(item);
itemEl.container.setAttribute('data-index', i);
itemEl.container.setAttribute('data-date', item.date);
addThumbnailFuncs[i] = addThumbnail;
itemElements.push(itemEl.container);
}
function layout() {
const width = container.getBoundingClientRect().width;
container = emptyElement(container);
function addRow(itemEls, rowWidth, container) {
const totalSpacing = itemSpacing * (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 = `${itemSpacing}px`;
for (const itemEl of itemEls) {
const index = parseInt(itemEl.getAttribute('data-index'), 10);
const item = items[index];
const aspect = item.width / item.height;
const baseWidth = (aspect * rowHeight) * scaleFactor;
const actualWidth = baseWidth - (totalSpacing / itemEls.length);
itemEl.style.width = `${actualWidth}px`;
itemEl.style.height = `${scaleFactor * rowHeight}px`;
itemEl.style.marginRight = `${itemSpacing}px`;
row.appendChild(itemEl);
}
container.appendChild(row);
}
let rowItemEls = [];
let rowWidth = 0;
for (const itemEl of itemElements) {
const index = parseInt(itemEl.getAttribute('data-index'), 10);
const item = items[index];
const aspect = item.width / item.height;
rowWidth += aspect * rowHeight;
rowItemEls.push(itemEl);
if (rowWidth > width) {
addRow(rowItemEls, rowWidth, container);
rowItemEls = [];
rowWidth = 0;
}
}
addRow(rowItemEls, rowWidth, container);
}
function tryLoadImages() {
const OFFSCREEN_THRESHOLD = 0;
const rect = container.getBoundingClientRect();
const offscreen = (
rect.bottom < -OFFSCREEN_THRESHOLD
|| rect.top > window.innerHeight + OFFSCREEN_THRESHOLD
);
if (!offscreen) {
const items = container.querySelectorAll('.showcase-item');
for (const item of items) {
const i = parseInt(item.getAttribute('data-index'), 10);
addThumbnailFuncs[i]();
}
}
}
window.addEventListener('DOMContentLoaded', () => {
layout();
layout(); // scrollbars are fun!!
tryLoadImages();
})
window.addEventListener('resize', () => {
layout();
tryLoadImages();
});
window.addEventListener('scroll', () => {
tryLoadImages();
});
}

View File

@ -7337,6 +7337,12 @@ article code {
flex-grow: 1; flex-grow: 1;
flex-shrink: 1; } } flex-shrink: 1; } }
@media screen and (min-width: 60em) {
.flex-fair-l {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1; } }
.b--theme { .b--theme {
border-color: #666; border-color: #666;
border-color: var(--theme-color); } border-color: var(--theme-color); }
@ -7397,6 +7403,10 @@ article code {
background-color: #f8f8f8; background-color: #f8f8f8;
background-color: var(--content-background); } background-color: var(--content-background); }
.bg--card {
background-color: #e8e8e8;
background-color: var(--card-background); }
.f8 { .f8 {
font-size: 0.65rem; } font-size: 0.65rem; }
@ -9016,66 +9026,21 @@ span.icon-rss::before {
.chat #users .user { .chat #users .user {
cursor: pointer; } cursor: pointer; }
.landing .breadcrumb { .landing-layout {
padding: 0px 3px; } display: grid;
.landing .breadcrumb:first-child { gap: 1rem; }
padding-left: 0px; } .landing-layout > * {
overflow: hidden; }
.landing .more { @media screen and (min-width: 60em) {
display: block; .landing-layout {
margin-top: 10px; } grid-template-columns: 1fr;
grid-auto-columns: 1fr; }
.landing .contents { .landing-layout > * {
margin-bottom: 20px; } grid-column: 1 / 2; }
.landing-layout > *.landing-right {
.landing .showcase .arrow-container { grid-column: 2 / 3;
width: 60px; grid-row: 1 / 20; } }
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center; }
.landing .showcase .arrow-container.left {
left: 0;
background-image: linear-gradient(to right, #f8f8f8 , rgba(0, 0, 0, 0));
background-image: linear-gradient(to right, var(--content-background) , rgba(0, 0, 0, 0)); }
.landing .showcase .arrow-container.left svg {
transform: translateX(-0.05rem); }
.landing .showcase .arrow-container.right {
right: 0;
background-image: linear-gradient(to left, #f8f8f8 , rgba(0, 0, 0, 0));
background-image: linear-gradient(to left, var(--content-background) , rgba(0, 0, 0, 0)); }
.landing .showcase .arrow-container.right svg {
transform: translateX(0.05rem); }
.landing .showcase .arrow-container.hide {
opacity: 0;
pointer-events: none; }
.landing .showcase .arrow-container .arrow {
background-color: #f8f8f8;
background-color: var(--content-background);
border-radius: 100%;
width: 2.4rem;
height: 2.4rem;
font-size: 1rem;
transition: opacity 40ms ease-in-out;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.2);
display: flex;
justify-content: center;
align-items: center; }
.landing #showcase-items {
transition: transform 200ms ease-in-out; }
.landing .showcase-item {
width: 7rem;
height: 7rem; }
@media screen and (min-width: 30em) {
.landing .showcase-item {
width: 10rem;
height: 10rem; } }
.star-btn { .star-btn {
border-bottom-width: 2px; border-bottom-width: 2px;
@ -9340,9 +9305,13 @@ span.icon-rss::before {
border: 2px solid; border: 2px solid;
border-color: #666; border-color: #666;
border-color: var(--theme-color); } border-color: var(--theme-color); }
.timeline-item .avatar-icon.big {
width: 3rem;
height: 3rem; }
@media screen and (min-width: 30em) {
.timeline-item .avatar-icon.big { .timeline-item .avatar-icon.big {
width: 3.875rem; width: 3.875rem;
height: 3.875rem; } height: 3.875rem; } }
.timeline-item .timeline-content-box > * { .timeline-item .timeline-content-box > * {
display: block; display: block;
max-width: 100%; max-width: 100%;

View File

@ -285,8 +285,8 @@ pre, code, .codeblock {
--forum-diff-delete-border-color: #6b1e1c; --forum-diff-delete-border-color: #6b1e1c;
--forum-diff-insert-background: #233a18; --forum-diff-insert-background: #233a18;
--forum-diff-insert-border-color: #30591b; --forum-diff-insert-border-color: #30591b;
--card-background: #222; --card-background: #282828;
--card-background-hover: #282828; --card-background-hover: #333;
--irc-border-color: #333; --irc-border-color: #333;
--irc-tab-current-shadow: 0px 0px 5px #000 inset; --irc-tab-current-shadow: 0px 0px 5px #000 inset;
--irc-tab-close-button-color: #bbb; --irc-tab-close-button-color: #bbb;

View File

@ -189,6 +189,14 @@ article code {
} }
} }
@media #{$breakpoint-large} {
.flex-fair-l {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1;
}
}
.b--theme { .b--theme {
@include usevar(border-color, theme-color); @include usevar(border-color, theme-color);
} }
@ -249,6 +257,10 @@ article code {
@include usevar(background-color, content-background); @include usevar(background-color, content-background);
} }
.bg--card {
@include usevar(background-color, card-background);
}
.f8 { .f8 {
font-size: 0.65rem; font-size: 0.65rem;
} }

View File

@ -1,86 +1,24 @@
.landing { .landing-layout {
.breadcrumb { display: grid;
padding: 0px 3px; gap: $spacing-medium;
&:first-child { > * {
padding-left: 0px; overflow: hidden;
} }
} }
.more { @media #{$breakpoint-large} {
display: block; .landing-layout {
margin-top: 10px; grid-template-columns: 1fr;
} grid-auto-columns: 1fr;
.contents { > * {
margin-bottom: 20px; grid-column: 1 / 2;
}
.showcase { &.landing-right {
.arrow-container { grid-column: 2 / 3;
width: 60px; grid-row: 1 / 20; // increase this number if somehow you ever add that much garbage to the home page :)
position: absolute;
top: 0;
bottom: 0;
z-index: 2;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
$svg-scoot: 0.05rem;
&.left {
left: 0;
@include usevar(background-image, 'linear-gradient(to right, ' content-background ', rgba(0, 0, 0, 0))');
svg {
transform: translateX(-$svg-scoot);
}
}
&.right {
right: 0;
@include usevar(background-image, 'linear-gradient(to left, ' content-background ', rgba(0, 0, 0, 0))');
svg {
transform: translateX($svg-scoot);
}
}
&.hide {
opacity: 0;
pointer-events: none;
}
.arrow {
@include usevar(background-color, content-background);
border-radius: 100%;
width: 2.4rem;
height: 2.4rem;
font-size: 1rem;
transition: opacity 40ms ease-in-out;
box-shadow: 0px 0px 3px 0px rgba(0, 0, 0, 0.20);
display: flex;
justify-content: center;
align-items: center;
} }
} }
} }
#showcase-items {
transition: transform 200ms ease-in-out;
}
.showcase-item {
width: 7rem;
height: 7rem;
@media #{$breakpoint-not-small} {
width: 10rem;
height: 10rem;
}
}
} }

View File

@ -8,10 +8,15 @@
@include usevar(border-color, theme-color); @include usevar(border-color, theme-color);
&.big { &.big {
width: 3rem;
height: 3rem;
@media #{$breakpoint-not-small} {
width: px2rem(62px); width: px2rem(62px);
height: px2rem(62px); height: px2rem(62px);
} }
} }
}
.timeline-content-box > * { .timeline-content-box > * {
display: block; display: block;

View File

@ -95,8 +95,8 @@ $vars: (
forum-diff-insert-background: #233a18, forum-diff-insert-background: #233a18,
forum-diff-insert-border-color: #30591b, forum-diff-insert-border-color: #30591b,
card-background: #222, card-background: #282828,
card-background-hover: #282828, card-background-hover: #333,
irc-border-color: #333, irc-border-color: #333,
irc-tab-current-shadow: 0px 0px 5px #000 inset, irc-tab-current-shadow: 0px 0px 5px #000 inset,

View File

@ -249,6 +249,7 @@ func TimelineItemsToJSON(items []TimelineItem) string {
builder := strings.Builder{} builder := strings.Builder{}
builder.WriteRune('[') builder.WriteRune('[')
for i, item := range items { for i, item := range items {
if i > 0 { if i > 0 {
builder.WriteRune(',') builder.WriteRune(',')
} }

View File

@ -4,7 +4,7 @@
<div class="content-block"> <div class="content-block">
<div class="optionbar"> <div class="optionbar">
<div class="options"> <div class="options">
<a class="button" href="{{ .AtomFeedUrl }}"><span class="icon big pr1">4</span> RSS Feed</span></a> <a class="button" href="{{ .AtomFeedUrl }}"><span class="icon big pr1">4</span> RSS Feed</a>
{{ if .User }} {{ if .User }}
<form method="POST" action="{{ .MarkAllReadUrl }}"> <form method="POST" action="{{ .MarkAllReadUrl }}">
{{ csrftoken .Session }} {{ csrftoken .Session }}

View File

@ -1,3 +1,5 @@
<script src="{{ static "js/showcase.js" }}"></script>
<template id="showcase_item"> <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="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 data-tmpl="thumbnail" class="absolute absolute--fill z-0 flex justify-start items-center bg-left cover"></div>

View File

@ -1,10 +1,8 @@
{{ $small := or .Description .EmbedMedia }}
<div class="timeline-item flex flex-column pa3 mb2 br3" {{ with .FilterTitle }}data-filter-title="{{ . }}"{{ end }}> <div class="timeline-item flex flex-column pa3 mb2 br3" {{ with .FilterTitle }}data-filter-title="{{ . }}"{{ end }}>
{{/* top bar - avatar, info, date */}} {{/* top bar - avatar, info, date */}}
<div class="flex items-center"> <div class="flex items-center">
<a class="flex-shrink-0" href="{{ .OwnerUrl }}"> <a class="flex-shrink-0" href="{{ .OwnerUrl }}">
<img class="avatar-icon lite {{ if not $small }}big{{ end }} {{ if $small }}mr2{{ else }}mr3{{ end }}" src="{{ .OwnerAvatarUrl }}" /> <img class="avatar-icon lite {{ if not .SmallInfo }}big{{ end }} {{ if .SmallInfo }}mr2{{ else }}mr3{{ end }}" src="{{ .OwnerAvatarUrl }}" />
</a> </a>
<div class="overflow-hidden flex-grow-1 flex flex-column justify-center"> <div class="overflow-hidden flex-grow-1 flex flex-column justify-center">
@ -13,19 +11,19 @@
{{ end }} {{ end }}
{{ if .Title }} {{ if .Title }}
<div class="f5 nowrap truncate"> <div class="f5 nowrap truncate">
{{ with .TypeTitle }}<b>{{ . }}:</b>{{ end }} {{ with .TypeTitle }}<b class="dn di-ns">{{ . }}:</b>{{ end }}
<a href="{{ .Url }}">{{ .Title }}</a> <a href="{{ .Url }}">{{ .Title }}</a>
</div> </div>
{{ end }} {{ end }}
<div class="details"> <div class="details">
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a> <a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
{{ if not $small }} {{ if not .SmallInfo }}
&mdash; {{ timehtml (relativedate .Date) .Date }} &mdash; {{ timehtml (relativedate .Date) .Date }}
{{ end }} {{ end }}
</div> </div>
</div> </div>
{{ if $small }} {{ if .SmallInfo }}
<a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a> <a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
{{ end }} {{ end }}
</div> </div>
@ -39,6 +37,11 @@
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div> <div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
{{ end }} {{ end }}
</div> </div>
{{ if .TruncateDescription }}
<div class="mt2">
<a href="{{ .Url }}">Read more »</a>
</div>
{{ end }}
{{ end }} {{ end }}
{{ range .EmbedMedia }} {{ range .EmbedMedia }}

View File

@ -1,26 +1,7 @@
{{ template "base.html" . }} {{ template "base.html" . }}
{{ define "extrahead" }} {{ define "extrahead" }}
<style type="text/css">
{{ $base := . }}
{{ range $col := .PostColumns }}
{{ range $entry := $col }}
{{ $c1 := hex2color .Project.Color1 }}
{{ $linkColor := eq $base.Theme "dark" | ternary (lightness 0.55 $c1) (lightness 0.35 $c1) | color2css }}
{{ $linkHoverColor := eq $base.Theme "dark" | ternary (lightness 0.65 $c1) (lightness 0.45 $c1) | color2css }}
{{ $projectPostBackground := eq $base.Theme "dark" | ternary (lightness 0.15 $c1) (lightness 0.95 $c1) | alpha 0.2 | color2css }}
#p{{ .Project.Subdomain }} a.project-title { color: {{ $linkColor }}; }
#p{{ .Project.Subdomain }} .unread a { color: {{ $linkColor }}; }
#p{{ .Project.Subdomain }} .unread a:hover { color: {{ $linkHoverColor }} }
#p{{ .Project.Subdomain }} .unread .avatar-icon { border-color: {{ $linkColor }}; }
#p{{ .Project.Subdomain }} .post-list-item:nth-of-type(even) { background-color: {{ $projectPostBackground }}; }
#p{{ .Project.Subdomain }} .thread.more { background-color:transparent; }
{{ end }}
{{ end }}
</style>
<script src="{{ static "js/templates.js" }}"></script> <script src="{{ static "js/templates.js" }}"></script>
<script src="{{ static "js/showcase.js" }}"></script>
{{ end }} {{ end }}
{{ define "content" }} {{ define "content" }}
@ -77,186 +58,63 @@
</div> </div>
</a> </a>
</div> </div>
{{ template "showcase_templates.html" }}
<div class="landing-layout ph3 ph0-ns">
{{/*
The order of the grid children should be as desired on mobile, then adapted to larger
sizes using CSS grid properties.
*/}}
<div>
<h2>Latest News</h2>
{{ template "timeline_item.html" .NewsPost }}
</div>
<div class="landing-right">
<h2>Around the Network</h2>
<div class="optionbar mb2">
<div class="options">
<a class="button" href="{{ .AtomFeedUrl }}"><span class="icon big pr1">4</span> RSS Feed</a>
{{ if .User }}
<form method="POST" action="{{ .MarkAllReadUrl }}">
{{ csrftoken .Session }}
<button type="submit"><span class="big pr1">&#x2713;</span> Mark all posts on site as read</button>
</form>
{{ end }}
</div>
<div class="options">
{{ template "pagination.html" .Pagination }}
</div>
</div>
{{ range .TimelineItems }}
{{ template "timeline_item.html" . }}
{{ end }}
<div class="optionbar bottom mt2">
<div class="options"></div>
<div class="options">
{{ template "pagination.html" .Pagination }}
</div>
</div>
</div>
{{ if .ShowcaseTimelineJson }} {{ if .ShowcaseTimelineJson }}
<div class="content-block pb3"> <div>
<div class="tc tl-l w-100 pb2"> {{ template "showcase_templates.html" }}
<h2 class="di-l mr2-l">Community Showcase</h2> <div>
<ul class="list dib-l"> <h2>Community Showcase</h2>
<li class="dib-ns ma0 ph2"> <div class="bg--card pa3 br3">
<a href="{{ .ShowcaseUrl }}">View all</a> <div class="mb3">
</li>
</ul>
</div>
<div class="showcase relative overflow-hidden">
<div id="showcase-items" class="flex relative pl3 pl0-ns"></div>
<div class="arrow-container left">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('left')">{{ svg "chevron-left" }}</a>
</div>
<div class="arrow-container right">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('right')">{{ svg "chevron-right" }}</a>
</div>
</div>
<div class="c--dimmer i pv2 ph3 ph0-ns">
This is a selection of recent work done by community members. Want to participate? <a href="{{ .DiscordUrl }}" target="_blank">Join us on Discord.</a> This is a selection of recent work done by community members. Want to participate? <a href="{{ .DiscordUrl }}" target="_blank">Join us on Discord.</a>
</div> </div>
<div id="showcase-container"></div>
<div>
<a class="db w-100 tc pa2" href="{{ .ShowcaseUrl }}">View all »</a>
</div>
</div>
</div> </div>
<script> <script>
const timelineData = JSON.parse("{{ .ShowcaseTimelineJson }}"); const showcaseItems = JSON.parse("{{ .ShowcaseTimelineJson }}");
initShowcaseContainer(document.querySelector('#showcase-container'), showcaseItems, 200);
const showcaseEl = document.querySelector('#showcase-items');
for (const item of timelineData) {
const [itemEl, addThumbnail] = makeShowcaseItem(item);
addThumbnail();
itemEl.container.classList.add('mr3');
showcaseEl.appendChild(itemEl.root);
}
function rem2px(rem) {
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
}
function scrollShowcase(direction = null) {
const ITEM_WIDTH = showcaseEl.querySelector('.showcase-item').getBoundingClientRect().width;
const ITEM_SPACING = rem2px(1);
const showcaseWidth = showcaseEl.getBoundingClientRect().width;
const numVisible = showcaseWidth / (ITEM_WIDTH + ITEM_SPACING);
const scrollMagnitude = Math.floor(numVisible) - 1;
const scrollDirection = (direction === 'right' ? 1 : (direction === 'left' ? -1 : 0));
const scrollAmount = scrollMagnitude * scrollDirection;
const minIndex = 0;
const maxIndex = timelineData.length - Math.floor(numVisible);
const currentScrollIndex = parseInt(showcaseEl.getAttribute('data-scroll-index'), 10) || 0;
const newScrollIndex = Math.max(minIndex, Math.min(maxIndex, currentScrollIndex + scrollAmount));
showcaseEl.style.transform = `translateX(${-newScrollIndex * (ITEM_WIDTH + ITEM_SPACING)}px)`;
showcaseEl.setAttribute('data-scroll-index', newScrollIndex);
const leftArrowEl = document.querySelector('.arrow-container.left');
const rightArrowEl = document.querySelector('.arrow-container.right');
leftArrowEl.classList.toggle('hide', newScrollIndex === minIndex);
rightArrowEl.classList.toggle('hide', newScrollIndex === maxIndex);
}
scrollShowcase(); // force a scroll as an easy way to initialize styles
window.addEventListener('resize', () => scrollShowcase());
</script> </script>
{{ end }}
<div class="content-block">
<div class="optionbar pb2">
<div class="tc tl-l w-100">
<h2 class="di-l mr2-l">Around the Network</h2>
<ul class="list dib-l">
<li class="dib-ns ma0 ph2">
<a href="{{ .FeedUrl }}">View all posts on HMN</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="{{ .PodcastUrl }}">Podcast</a>
</li>
{{/* TODO: Make a better IRC intro page because the current one is trash anyway */}}
{{/*
<li class="dib-ns ma0 ph2">
<a href="{{ .StreamsUrl }}">See who's live</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="{{ .IRCUrl }}" target="_blank">Chat in IRC</a>
</li>
*/}}
<li class="dib-ns ma0 ph2">
<a href="{{ .DiscordUrl }}" target="_blank">Chat on Discord</a>
</li>
<li class="dib-ns ma0 ph2">
<a href="{{ .ShowUrl }}" target="_blank">See the Show</a>
</li>
</ul>
</div>
</div>
</div>
<div class="content-block news cf">
{{ $newsPost := .NewsPost }}
{{ range $i, $col := .PostColumns }}
<div class="fl w-100 w-50-l">
<div class="mw7 mw-none-l center-layout">
{{ if eq $i 0 }}
<div class="pt3">
{{ template "landing_page_featured_post" $newsPost}}
</div>
{{ end }}
{{ range $entry := $col }}
{{ $proj := $entry.Project }}
{{ $posts := $entry.Posts }}
<div class="pt3" id="p{{ $proj.Subdomain }}">
{{ $c1 := hex2color $proj.Color1 }}
<a
class="project-title"
href="{{ $proj.Url }}"
>
<h2 class="ph3">{{ $proj.Name }}</h2>
</a>
{{ with $entry.FeaturedPost }}
{{ template "landing_page_featured_post" . }}
{{ end }}
{{ range $post := $posts }}
{{ template "post_list_item" $post }}
{{ end }}
<div class="ph3 thread unread more">
<a class="title" href="{{ $entry.ForumsUrl }}">
More posts &rarr;
</a>
</div>
</div>
{{ end }}
</div>
</div> </div>
{{ end }} {{ end }}
</div> </div>
{{ end }} {{ end }}
{{ define "landing_page_featured_post" }}
{{/* Call this template with a LandingPageFeaturedPost. */}}
<div class="flex items-start ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }}">
<img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}">
<div class="flex-grow-1 overflow-hidden">
<div class="title mb1"><a href="{{ .Url }}">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
<div class="overflow-hidden mh-5 mt2 relative">
<div>
{{ .Content }}
</div>
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
</div>
<div class="mt2">
<a href="{{ .Url }}">Read More &rarr;</a>
</div>
</div>
</div>
{{ end }}
{{ define "post_list_item" }}
{{/* Call this template with a PostListItem. */}}
<div class="post-list-item flex items-center ph3 pv2 {{ if .Unread }}unread{{ else }}read{{ end }} {{ .Classes }}">
<img class="avatar-icon mr2" src="{{ .User.AvatarUrl }}">
<div class="flex-grow-1 overflow-hidden">
<div class="title nowrap truncate"><a href="{{ .Url }}" title="{{ .Preview }}">{{ .Title }}</a></div>
<div class="details">
<a class="user" href="{{ .User.ProfileUrl }}">{{ .User.Name }}</a> &mdash; {{ timehtml (relativedate .Date) .Date }}
</div>
</div>
<div class="goto">
<a href="{{ .Url }}">&raquo;</a>
</div>
</div>
{{ end }}

View File

@ -2,10 +2,11 @@
{{ define "extrahead" }} {{ define "extrahead" }}
<script src="{{ static "js/templates.js" }}"></script> <script src="{{ static "js/templates.js" }}"></script>
<script src="{{ static "js/showcase.js" }}"></script>
{{ end }} {{ end }}
{{ define "content" }} {{ define "content" }}
{{ template "showcase_templates.html" }}
<div class="content-block"> <div class="content-block">
<div class="ph2 ph0-ns pb4"> <div class="ph2 ph0-ns pb4">
<div class="optionbar"> <div class="optionbar">
@ -22,150 +23,50 @@
</div> </div>
</div> </div>
{{ template "showcase_templates.html" }}
<template id="showcase-month"> <template id="showcase-month">
<h3 data-tmpl="dateHeader" class="mt3 f4 fw5">Unknown Date</h3> <h3 data-tmpl="dateHeader" class="mt3 f4 fw5">Unknown Date</h3>
<div data-tmpl="itemsContainer" class="month-container"></div> <div data-tmpl="itemsContainer" class="month-container"></div>
</template> </template>
<script> <script>
const ROW_HEIGHT = 300; const showcaseItems = JSON.parse("{{ .ShowcaseItems }}");
const ITEM_SPACING = 4;
const monthTemplate = makeTemplateCloner('showcase-month'); const monthTemplate = makeTemplateCloner('showcase-month');
const showcaseContainer = document.querySelector('#showcase-container');
const showcaseItems = JSON.parse("{{ .ShowcaseItems }}"); const itemsByMonth = []; // array of arrays
const addThumbnailFuncs = new Array(showcaseItems.length);
let showcaseContainer = document.querySelector('#showcase-container');
const itemElementsByMonth = []; // array of arrays
let currentMonthElements = [];
let currentMonth = null; let currentMonth = null;
let currentYear = null; let currentYear = null;
for (let i = 0; i < showcaseItems.length; i++) { let currentMonthItems = [];
const item = showcaseItems[i]; for (const item of showcaseItems) {
const date = new Date(item.date * 1000); const date = new Date(item.date * 1000);
if (date.getMonth() !== currentMonth || date.getFullYear() !== currentYear) { if (date.getMonth() !== currentMonth || date.getFullYear() !== currentYear) {
if (currentMonthElements.length > 0) { // rolled over to new month
itemElementsByMonth.push(currentMonthElements); if (currentMonthItems.length > 0) {
itemsByMonth.push(currentMonthItems);
} }
currentMonthElements = [];
currentMonth = date.getMonth(); currentMonth = date.getMonth();
currentYear = date.getFullYear(); currentYear = date.getFullYear();
currentMonthItems = [];
} }
const [itemEl, addThumbnail] = makeShowcaseItem(item); currentMonthItems.push(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) { if (currentMonthItems.length > 0) {
itemElementsByMonth.push(currentMonthElements); itemsByMonth.push(currentMonthItems);
} }
function layout() { for (const monthItems of itemsByMonth) {
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 month = monthTemplate();
const firstDate = new Date(parseFloat(monthEls[0].getAttribute('data-date')) * 1000); const firstDate = new Date(monthItems[0].date * 1000);
month.dateHeader.textContent = firstDate.toLocaleDateString([], { month: 'long', year: 'numeric' }); month.dateHeader.textContent = firstDate.toLocaleDateString([], { month: 'long', year: 'numeric' });
let rowItemEls = []; initShowcaseContainer(month.root.querySelector('.month-container'), monthItems);
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); 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> </script>
{{ end }} {{ end }}

View File

@ -263,6 +263,7 @@ type TimelineItem struct {
PreviewMedia TimelineItemMedia PreviewMedia TimelineItemMedia
EmbedMedia []TimelineItemMedia EmbedMedia []TimelineItemMedia
SmallInfo bool
CanShowcase bool // whether this snippet can be shown in a showcase gallery CanShowcase bool // whether this snippet can be shown in a showcase gallery
} }

View File

@ -26,19 +26,24 @@ type FeedData struct {
MarkAllReadUrl string MarkAllReadUrl string
} }
func Feed(c *RequestContext) ResponseData { const feedPostsPerPage = 30
const postsPerPage = 30
var feedThreadTypes = []models.ThreadType{
models.ThreadTypeForumPost,
models.ThreadTypeProjectBlogPost,
}
func Feed(c *RequestContext) ResponseData {
numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{ numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost}, ThreadTypes: feedThreadTypes,
}) })
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err) return c.ErrorResponse(http.StatusInternalServerError, err)
} }
numPages := int(math.Ceil(float64(numPosts) / postsPerPage)) numPages := int(math.Ceil(float64(numPosts) / feedPostsPerPage))
page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, postsPerPage) page, numPages, ok := getPageInfo(c.PathParams["page"], numPosts, feedPostsPerPage)
if !ok { if !ok {
return c.Redirect(hmnurl.BuildFeed(), http.StatusSeeOther) return c.Redirect(hmnurl.BuildFeed(), http.StatusSeeOther)
} }
@ -53,7 +58,7 @@ func Feed(c *RequestContext) ResponseData {
PreviousUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page-1, numPages)), PreviousUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page-1, numPages)),
} }
posts, err := fetchAllPosts(c, (page-1)*postsPerPage, postsPerPage) posts, err := fetchAllPosts(c, (page-1)*feedPostsPerPage, feedPostsPerPage)
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch feed posts"))
} }
@ -272,7 +277,7 @@ func AtomFeed(c *RequestContext) ResponseData {
func fetchAllPosts(c *RequestContext, offset int, limit int) ([]templates.PostListItem, error) { func fetchAllPosts(c *RequestContext, offset int, limit int) ([]templates.PostListItem, error) {
postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{ postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost}, ThreadTypes: feedThreadTypes,
Limit: limit, Limit: limit,
Offset: offset, Offset: offset,
SortDescending: true, SortDescending: true,

View File

@ -1,23 +1,24 @@
package website package website
import ( import (
"fmt"
"html/template" "html/template"
"math"
"net/http" "net/http"
"time"
"git.handmade.network/hmn/hmn/src/db" "git.handmade.network/hmn/hmn/src/db"
"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"
"git.handmade.network/hmn/hmn/src/oops" "git.handmade.network/hmn/hmn/src/oops"
"git.handmade.network/hmn/hmn/src/templates" "git.handmade.network/hmn/hmn/src/templates"
"git.handmade.network/hmn/hmn/src/utils"
) )
type LandingTemplateData struct { type LandingTemplateData struct {
templates.BaseData templates.BaseData
NewsPost LandingPageFeaturedPost NewsPost *templates.TimelineItem
PostColumns [][]LandingPageProject TimelineItems []templates.TimelineItem
Pagination templates.Pagination
ShowcaseTimelineJson string ShowcaseTimelineJson string
FeedUrl string FeedUrl string
@ -27,179 +28,61 @@ type LandingTemplateData struct {
DiscordUrl string DiscordUrl string
ShowUrl string ShowUrl string
ShowcaseUrl string ShowcaseUrl string
AtomFeedUrl string
MarkAllReadUrl string
WheelJamUrl string WheelJamUrl string
} }
type LandingPageProject struct {
Project templates.Project
FeaturedPost *LandingPageFeaturedPost
Posts []templates.PostListItem
ForumsUrl string
}
type LandingPageFeaturedPost struct {
Title string
Url string
User templates.User
Date time.Time
Unread bool
Content template.HTML
}
func Index(c *RequestContext) ResponseData { func Index(c *RequestContext) ResponseData {
const maxPosts = 5
const numProjectsToGet = 7
c.Perf.StartBlock("SQL", "Fetch projects")
iterProjects, err := db.Query(c.Context(), c.Conn, models.Project{},
`
SELECT $columns
FROM handmade_project
WHERE
(flags = 0 AND lifecycle = ANY($1))
OR id = $2
ORDER BY all_last_updated DESC
LIMIT $3
`,
models.VisibleProjectLifecycles,
models.HMNProjectID,
numProjectsToGet*2, // hedge your bets against projects that don't have any content
)
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to get projects for home page"))
}
defer iterProjects.Close()
var pageProjects []LandingPageProject
allProjects := iterProjects.ToSlice()
c.Perf.EndBlock()
c.Perf.StartBlock("SQL", "Fetch subforum tree") c.Perf.StartBlock("SQL", "Fetch subforum tree")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn) subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree) lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock() c.Perf.EndBlock()
c.Perf.StartBlock("LANDING", "Process projects") var timelineItems []templates.TimelineItem
for _, projRow := range allProjects {
proj := projRow.(*models.Project)
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name)) numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
projectPosts, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{ ThreadTypes: feedThreadTypes,
ProjectIDs: []int{proj.ID}, })
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost, models.ThreadTypeForumPost}, if err != nil {
Limit: maxPosts, return c.ErrorResponse(http.StatusInternalServerError, err)
}
numPages := int(math.Ceil(float64(numPosts) / feedPostsPerPage))
page, numPages, ok := getPageInfo("1", numPosts, feedPostsPerPage)
if !ok {
return c.Redirect(hmnurl.BuildFeed(), http.StatusSeeOther)
}
pagination := templates.Pagination{
Current: page,
Total: numPages,
FirstUrl: hmnurl.BuildFeed(),
LastUrl: hmnurl.BuildFeedWithPage(numPages),
NextUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page+1, numPages)),
PreviousUrl: hmnurl.BuildFeedWithPage(utils.IntClamp(1, page-1, numPages)),
}
// This is essentially an alternate for feed page 1.
posts, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: feedThreadTypes,
Limit: feedPostsPerPage,
SortDescending: true, SortDescending: true,
}) })
c.Perf.EndBlock()
if err != nil { if err != nil {
c.Logger.Error().Err(err).Msg("failed to fetch project posts") c.Logger.Warn().Err(err).Msg("failed to fetch latest posts")
continue
} }
for _, p := range posts {
forumsUrl := "" item := PostToTimelineItem(lineageBuilder, &p.Post, &p.Thread, &p.Project, p.Author, c.Theme)
if proj.ForumID != nil { if p.Thread.Type == models.ThreadTypeProjectBlogPost && p.Post.ID == p.Thread.FirstID {
forumsUrl = hmnurl.BuildForum(proj.Slug, lineageBuilder.GetSubforumLineageSlugs(*proj.ForumID), 1) // blog post
} else { item.Description = template.HTML(p.CurrentVersion.TextParsed)
c.Logger.Error().Int("ProjectID", proj.ID).Str("ProjectName", proj.Name).Msg("Project fetched by landing page but it doesn't have forums") item.TruncateDescription = true
}
landingPageProject := LandingPageProject{
Project: templates.ProjectToTemplate(proj, c.Theme),
ForumsUrl: forumsUrl,
}
for _, projectPost := range projectPosts {
featurable := (!proj.IsHMN() &&
projectPost.Post.ThreadType == models.ThreadTypeProjectBlogPost &&
projectPost.Thread.FirstID == projectPost.Post.ID &&
landingPageProject.FeaturedPost == nil)
if featurable {
type featuredContentResult struct {
Content string `db:"ver.text_parsed"`
}
c.Perf.StartBlock("SQL", "Fetch featured post content")
contentResult, err := db.QueryOne(c.Context(), c.Conn, featuredContentResult{}, `
SELECT $columns
FROM
handmade_post AS post
JOIN handmade_postversion AS ver ON post.current_id = ver.id
WHERE
post.id = $1
`, projectPost.Post.ID)
c.Perf.EndBlock()
if err != nil {
c.Logger.Error().Err(err).Msg("failed to fetch featured post content")
continue
}
content := contentResult.(*featuredContentResult).Content
landingPageProject.FeaturedPost = &LandingPageFeaturedPost{
Title: projectPost.Thread.Title,
Url: hmnurl.BuildBlogThread(proj.Slug, projectPost.Thread.ID, projectPost.Thread.Title),
User: templates.UserToTemplate(projectPost.Author, c.Theme),
Date: projectPost.Post.PostDate,
Unread: projectPost.Unread,
Content: template.HTML(content),
}
} else {
landingPageProject.Posts = append(
landingPageProject.Posts,
MakePostListItem(
lineageBuilder,
proj,
&projectPost.Thread,
&projectPost.Post,
projectPost.Author,
projectPost.Unread,
false,
c.Theme,
),
)
}
}
if len(projectPosts) > 0 {
pageProjects = append(pageProjects, landingPageProject)
}
if len(pageProjects) >= numProjectsToGet {
break
}
}
c.Perf.EndBlock()
/*
Columns are filled by placing projects into the least full column.
The fill array tracks the estimated sizes.
This is all hardcoded for two columns; deal with it.
*/
cols := [][]LandingPageProject{nil, nil}
fill := []int{4, 0}
featuredIndex := []int{0, 0}
for _, pageProject := range pageProjects {
leastFullColumnIndex := indexOfSmallestInt(fill)
numNewPosts := len(pageProject.Posts)
if numNewPosts > maxPosts {
numNewPosts = maxPosts
}
fill[leastFullColumnIndex] += numNewPosts
if pageProject.FeaturedPost != nil {
fill[leastFullColumnIndex] += 2 // featured posts add more to height
// projects with featured posts go at the top of the column
cols[leastFullColumnIndex] = append(cols[leastFullColumnIndex], pageProject)
featuredIndex[leastFullColumnIndex] += 1
} else {
cols[leastFullColumnIndex] = append(cols[leastFullColumnIndex], pageProject)
} }
timelineItems = append(timelineItems, item)
} }
c.Perf.StartBlock("SQL", "Get news") c.Perf.StartBlock("SQL", "Get news")
@ -211,9 +94,14 @@ func Index(c *RequestContext) ResponseData {
if err != nil { if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post")) return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post"))
} }
var newsThread ThreadAndStuff var newsPostItem *templates.TimelineItem
if len(newsThreads) > 0 { if len(newsThreads) > 0 {
newsThread = newsThreads[0] t := newsThreads[0]
item := PostToTimelineItem(lineageBuilder, &t.FirstPost, &t.Thread, &t.Project, t.FirstPostAuthor, c.Theme)
item.TypeTitle = ""
item.Description = template.HTML(t.FirstPostCurrentVersion.TextParsed)
item.TruncateDescription = true
newsPostItem = &item
} }
c.Perf.EndBlock() c.Perf.EndBlock()
@ -235,7 +123,7 @@ func Index(c *RequestContext) ResponseData {
WHERE WHERE
NOT snippet.is_jam NOT snippet.is_jam
ORDER BY snippet.when DESC ORDER BY snippet.when DESC
LIMIT 20 LIMIT 40
`, `,
) )
if err != nil { if err != nil {
@ -266,6 +154,12 @@ func Index(c *RequestContext) ResponseData {
var res ResponseData var res ResponseData
err = res.WriteTemplate("landing.html", LandingTemplateData{ err = res.WriteTemplate("landing.html", LandingTemplateData{
BaseData: baseData, BaseData: baseData,
NewsPost: newsPostItem,
TimelineItems: timelineItems,
Pagination: pagination,
ShowcaseTimelineJson: showcaseJson,
FeedUrl: hmnurl.BuildFeed(), FeedUrl: hmnurl.BuildFeed(),
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug), PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
StreamsUrl: hmnurl.BuildStreams(), StreamsUrl: hmnurl.BuildStreams(),
@ -273,16 +167,8 @@ func Index(c *RequestContext) ResponseData {
DiscordUrl: "https://discord.gg/hxWxDee", DiscordUrl: "https://discord.gg/hxWxDee",
ShowUrl: "https://handmadedev.show/", ShowUrl: "https://handmadedev.show/",
ShowcaseUrl: hmnurl.BuildShowcase(), ShowcaseUrl: hmnurl.BuildShowcase(),
NewsPost: LandingPageFeaturedPost{ AtomFeedUrl: hmnurl.BuildAtomFeed(),
Title: newsThread.Thread.Title, MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0),
Url: hmnurl.BuildBlogThread(models.HMNProjectSlug, newsThread.Thread.ID, newsThread.Thread.Title),
User: templates.UserToTemplate(newsThread.FirstPostAuthor, c.Theme),
Date: newsThread.FirstPost.PostDate,
Unread: true,
Content: template.HTML(newsThread.FirstPostCurrentVersion.TextParsed),
},
PostColumns: cols,
ShowcaseTimelineJson: showcaseJson,
WheelJamUrl: hmnurl.BuildJamIndex(), WheelJamUrl: hmnurl.BuildJamIndex(),
}, c.Perf) }, c.Perf)
@ -292,17 +178,3 @@ func Index(c *RequestContext) ResponseData {
return res return res
} }
func indexOfSmallestInt(s []int) int {
result := 0
min := s[result]
for i, val := range s {
if val < min {
result = i
min = val
}
}
return result
}

View File

@ -98,6 +98,10 @@ func SnippetToTimelineItem(
} }
} }
if len(item.EmbedMedia) > 0 && (item.EmbedMedia[0].Width == 0 || item.EmbedMedia[0].Height == 0) {
item.CanShowcase = false
}
if discordMessage != nil { if discordMessage != nil {
item.DiscordMessageUrl = discordMessage.Url item.DiscordMessageUrl = discordMessage.Url
} }

View File

@ -177,13 +177,15 @@ func UserProfile(c *RequestContext) ResponseData {
for _, snippetRow := range snippetQuerySlice { for _, snippetRow := range snippetQuerySlice {
snippetData := snippetRow.(*snippetQuery) snippetData := snippetRow.(*snippetQuery)
timelineItems = append(timelineItems, SnippetToTimelineItem( item := SnippetToTimelineItem(
&snippetData.Snippet, &snippetData.Snippet,
snippetData.Asset, snippetData.Asset,
snippetData.DiscordMessage, snippetData.DiscordMessage,
profileUser, profileUser,
c.Theme, c.Theme,
)) )
item.SmallInfo = true
timelineItems = append(timelineItems, item)
} }
c.Perf.StartBlock("PROFILE", "Sort timeline") c.Perf.StartBlock("PROFILE", "Sort timeline")