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

@ -39,7 +39,7 @@ function makeShowcaseItem(timelineItem) {
addThumbnailFunc = () => {
itemEl.thumbnail.style.backgroundImage = `url('${timelineItem.thumbnail_url}')`;
};
createModalContentFunc = () => {
const modalImage = document.createElement('img');
modalImage.src = timelineItem.asset_url;
@ -81,7 +81,7 @@ function makeShowcaseItem(timelineItem) {
break;
// TODO(ben): Other snippet types?
}
let modalEl = null;
itemEl.container.addEventListener('click', function() {
if (!modalEl) {
@ -113,3 +113,106 @@ function makeShowcaseItem(timelineItem) {
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-shrink: 1; } }
@media screen and (min-width: 60em) {
.flex-fair-l {
flex-basis: 1px;
flex-grow: 1;
flex-shrink: 1; } }
.b--theme {
border-color: #666;
border-color: var(--theme-color); }
@ -7397,6 +7403,10 @@ article code {
background-color: #f8f8f8;
background-color: var(--content-background); }
.bg--card {
background-color: #e8e8e8;
background-color: var(--card-background); }
.f8 {
font-size: 0.65rem; }
@ -9016,66 +9026,21 @@ span.icon-rss::before {
.chat #users .user {
cursor: pointer; }
.landing .breadcrumb {
padding: 0px 3px; }
.landing .breadcrumb:first-child {
padding-left: 0px; }
.landing-layout {
display: grid;
gap: 1rem; }
.landing-layout > * {
overflow: hidden; }
.landing .more {
display: block;
margin-top: 10px; }
.landing .contents {
margin-bottom: 20px; }
.landing .showcase .arrow-container {
width: 60px;
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; } }
@media screen and (min-width: 60em) {
.landing-layout {
grid-template-columns: 1fr;
grid-auto-columns: 1fr; }
.landing-layout > * {
grid-column: 1 / 2; }
.landing-layout > *.landing-right {
grid-column: 2 / 3;
grid-row: 1 / 20; } }
.star-btn {
border-bottom-width: 2px;
@ -9341,8 +9306,12 @@ span.icon-rss::before {
border-color: #666;
border-color: var(--theme-color); }
.timeline-item .avatar-icon.big {
width: 3.875rem;
height: 3.875rem; }
width: 3rem;
height: 3rem; }
@media screen and (min-width: 30em) {
.timeline-item .avatar-icon.big {
width: 3.875rem;
height: 3.875rem; } }
.timeline-item .timeline-content-box > * {
display: block;
max-width: 100%;

View File

@ -285,8 +285,8 @@ pre, code, .codeblock {
--forum-diff-delete-border-color: #6b1e1c;
--forum-diff-insert-background: #233a18;
--forum-diff-insert-border-color: #30591b;
--card-background: #222;
--card-background-hover: #282828;
--card-background: #282828;
--card-background-hover: #333;
--irc-border-color: #333;
--irc-tab-current-shadow: 0px 0px 5px #000 inset;
--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 {
@include usevar(border-color, theme-color);
}
@ -249,6 +257,10 @@ article code {
@include usevar(background-color, content-background);
}
.bg--card {
@include usevar(background-color, card-background);
}
.f8 {
font-size: 0.65rem;
}

View File

@ -1,86 +1,24 @@
.landing {
.breadcrumb {
padding: 0px 3px;
.landing-layout {
display: grid;
gap: $spacing-medium;
&:first-child {
padding-left: 0px;
}
> * {
overflow: hidden;
}
}
.more {
display: block;
margin-top: 10px;
}
@media #{$breakpoint-large} {
.landing-layout {
grid-template-columns: 1fr;
grid-auto-columns: 1fr;
.contents {
margin-bottom: 20px;
}
> * {
grid-column: 1 / 2;
.showcase {
.arrow-container {
width: 60px;
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);
}
&.landing-right {
grid-column: 2 / 3;
grid-row: 1 / 20; // increase this number if somehow you ever add that much garbage to the home page :)
}
&.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,8 +8,13 @@
@include usevar(border-color, theme-color);
&.big {
width: px2rem(62px);
height: px2rem(62px);
width: 3rem;
height: 3rem;
@media #{$breakpoint-not-small} {
width: px2rem(62px);
height: px2rem(62px);
}
}
}

View File

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

View File

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

View File

@ -4,7 +4,7 @@
<div class="content-block">
<div class="optionbar">
<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 }}
<form method="POST" action="{{ .MarkAllReadUrl }}">
{{ csrftoken .Session }}

View File

@ -1,18 +1,20 @@
<script src="{{ static "js/showcase.js" }}"></script>
<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 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">

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 }}>
{{/* top bar - avatar, info, date */}}
<div class="flex items-center">
<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>
<div class="overflow-hidden flex-grow-1 flex flex-column justify-center">
@ -13,19 +11,19 @@
{{ end }}
{{ if .Title }}
<div class="f5 nowrap truncate">
{{ with .TypeTitle }}<b>{{ . }}:</b>{{ end }}
{{ with .TypeTitle }}<b class="dn di-ns">{{ . }}:</b>{{ end }}
<a href="{{ .Url }}">{{ .Title }}</a>
</div>
{{ end }}
<div class="details">
<a class="user" href="{{ .OwnerUrl }}">{{ .OwnerName }}</a>
{{ if not $small }}
{{ if not .SmallInfo }}
&mdash; {{ timehtml (relativedate .Date) .Date }}
{{ end }}
</div>
</div>
{{ if $small }}
{{ if .SmallInfo }}
<a href="{{ .Url }}">{{ timehtml (relativedate .Date) .Date }}</a>
{{ end }}
</div>
@ -39,6 +37,11 @@
<div class="excerpt-fade absolute w-100 h4 bottom-0 z-999"></div>
{{ end }}
</div>
{{ if .TruncateDescription }}
<div class="mt2">
<a href="{{ .Url }}">Read more »</a>
</div>
{{ end }}
{{ end }}
{{ range .EmbedMedia }}

View File

@ -1,26 +1,7 @@
{{ template "base.html" . }}
{{ 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/showcase.js" }}"></script>
{{ end }}
{{ define "content" }}
@ -77,186 +58,63 @@
</div>
</a>
</div>
{{ template "showcase_templates.html" }}
{{ if .ShowcaseTimelineJson }}
<div class="content-block pb3">
<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="{{ .ShowcaseUrl }}">View all</a>
</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 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="arrow-container right">
<a href="javascript:void(0)" class="arrow svgicon svgicon-nofix" onclick="scrollShowcase('right')">{{ svg "chevron-right" }}</a>
<div class="options">
{{ template "pagination.html" .Pagination }}
</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>
{{ 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>
<script>
const timelineData = JSON.parse("{{ .ShowcaseTimelineJson }}");
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>
{{ 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>
{{ end }}
</div>
{{ 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>
{{ if .ShowcaseTimelineJson }}
<div>
{{ template "showcase_templates.html" }}
<div>
<h2>Community Showcase</h2>
<div class="bg--card pa3 br3">
<div class="mb3">
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 id="showcase-container"></div>
<div>
<a class="db w-100 tc pa2" href="{{ .ShowcaseUrl }}">View all »</a>
</div>
</div>
</div>
<script>
const showcaseItems = JSON.parse("{{ .ShowcaseTimelineJson }}");
initShowcaseContainer(document.querySelector('#showcase-container'), showcaseItems, 200);
</script>
</div>
{{ end }}
</div>
{{ end }}

View File

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

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

View File

@ -26,19 +26,24 @@ type FeedData struct {
MarkAllReadUrl string
}
func Feed(c *RequestContext) ResponseData {
const postsPerPage = 30
const feedPostsPerPage = 30
var feedThreadTypes = []models.ThreadType{
models.ThreadTypeForumPost,
models.ThreadTypeProjectBlogPost,
}
func Feed(c *RequestContext) ResponseData {
numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost},
ThreadTypes: feedThreadTypes,
})
if err != nil {
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 {
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)),
}
posts, err := fetchAllPosts(c, (page-1)*postsPerPage, postsPerPage)
posts, err := fetchAllPosts(c, (page-1)*feedPostsPerPage, feedPostsPerPage)
if err != nil {
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) {
postsAndStuff, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: []models.ThreadType{models.ThreadTypeForumPost, models.ThreadTypeProjectBlogPost},
ThreadTypes: feedThreadTypes,
Limit: limit,
Offset: offset,
SortDescending: true,

View File

@ -1,205 +1,88 @@
package website
import (
"fmt"
"html/template"
"math"
"net/http"
"time"
"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"
"git.handmade.network/hmn/hmn/src/utils"
)
type LandingTemplateData struct {
templates.BaseData
NewsPost LandingPageFeaturedPost
PostColumns [][]LandingPageProject
NewsPost *templates.TimelineItem
TimelineItems []templates.TimelineItem
Pagination templates.Pagination
ShowcaseTimelineJson string
FeedUrl string
PodcastUrl string
StreamsUrl string
IRCUrl string
DiscordUrl string
ShowUrl string
ShowcaseUrl string
FeedUrl string
PodcastUrl string
StreamsUrl string
IRCUrl string
DiscordUrl string
ShowUrl string
ShowcaseUrl string
AtomFeedUrl string
MarkAllReadUrl 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 {
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")
subforumTree := models.GetFullSubforumTree(c.Context(), c.Conn)
lineageBuilder := models.MakeSubforumLineageBuilder(subforumTree)
c.Perf.EndBlock()
c.Perf.StartBlock("LANDING", "Process projects")
for _, projRow := range allProjects {
proj := projRow.(*models.Project)
var timelineItems []templates.TimelineItem
c.Perf.StartBlock("SQL", fmt.Sprintf("Fetch posts for %s", proj.Name))
projectPosts, err := FetchPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ProjectIDs: []int{proj.ID},
ThreadTypes: []models.ThreadType{models.ThreadTypeProjectBlogPost, models.ThreadTypeForumPost},
Limit: maxPosts,
SortDescending: true,
})
c.Perf.EndBlock()
if err != nil {
c.Logger.Error().Err(err).Msg("failed to fetch project posts")
continue
}
forumsUrl := ""
if proj.ForumID != nil {
forumsUrl = hmnurl.BuildForum(proj.Slug, lineageBuilder.GetSubforumLineageSlugs(*proj.ForumID), 1)
} else {
c.Logger.Error().Int("ProjectID", proj.ID).Str("ProjectName", proj.Name).Msg("Project fetched by landing page but it doesn't have forums")
}
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
}
numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: feedThreadTypes,
})
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, err)
}
c.Perf.EndBlock()
/*
Columns are filled by placing projects into the least full column.
The fill array tracks the estimated sizes.
numPages := int(math.Ceil(float64(numPosts) / feedPostsPerPage))
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)
page, numPages, ok := getPageInfo("1", numPosts, feedPostsPerPage)
if !ok {
return c.Redirect(hmnurl.BuildFeed(), http.StatusSeeOther)
}
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)
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,
})
if err != nil {
c.Logger.Warn().Err(err).Msg("failed to fetch latest posts")
}
for _, p := range posts {
item := PostToTimelineItem(lineageBuilder, &p.Post, &p.Thread, &p.Project, p.Author, c.Theme)
if p.Thread.Type == models.ThreadTypeProjectBlogPost && p.Post.ID == p.Thread.FirstID {
// blog post
item.Description = template.HTML(p.CurrentVersion.TextParsed)
item.TruncateDescription = true
}
timelineItems = append(timelineItems, item)
}
c.Perf.StartBlock("SQL", "Get news")
@ -211,9 +94,14 @@ func Index(c *RequestContext) ResponseData {
if err != nil {
return c.ErrorResponse(http.StatusInternalServerError, oops.New(err, "failed to fetch news post"))
}
var newsThread ThreadAndStuff
var newsPostItem *templates.TimelineItem
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()
@ -235,7 +123,7 @@ func Index(c *RequestContext) ResponseData {
WHERE
NOT snippet.is_jam
ORDER BY snippet.when DESC
LIMIT 20
LIMIT 40
`,
)
if err != nil {
@ -265,25 +153,23 @@ func Index(c *RequestContext) ResponseData {
var res ResponseData
err = res.WriteTemplate("landing.html", LandingTemplateData{
BaseData: baseData,
FeedUrl: hmnurl.BuildFeed(),
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
StreamsUrl: hmnurl.BuildStreams(),
IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC"),
DiscordUrl: "https://discord.gg/hxWxDee",
ShowUrl: "https://handmadedev.show/",
ShowcaseUrl: hmnurl.BuildShowcase(),
NewsPost: LandingPageFeaturedPost{
Title: newsThread.Thread.Title,
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,
BaseData: baseData,
NewsPost: newsPostItem,
TimelineItems: timelineItems,
Pagination: pagination,
ShowcaseTimelineJson: showcaseJson,
FeedUrl: hmnurl.BuildFeed(),
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
StreamsUrl: hmnurl.BuildStreams(),
IRCUrl: hmnurl.BuildBlogThread(models.HMNProjectSlug, 1138, "[Tutorial] Handmade Network IRC"),
DiscordUrl: "https://discord.gg/hxWxDee",
ShowUrl: "https://handmadedev.show/",
ShowcaseUrl: hmnurl.BuildShowcase(),
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0),
WheelJamUrl: hmnurl.BuildJamIndex(),
}, c.Perf)
if err != nil {
@ -292,17 +178,3 @@ func Index(c *RequestContext) ResponseData {
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 {
item.DiscordMessageUrl = discordMessage.Url
}

View File

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