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)];
}
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;
@ -9340,9 +9305,13 @@ span.icon-rss::before {
border: 2px solid;
border-color: #666;
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 {
width: 3.875rem;
height: 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);
}
}
&.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;
&.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 :)
}
}
}
#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);
&.big {
width: 3rem;
height: 3rem;
@media #{$breakpoint-not-small} {
width: px2rem(62px);
height: px2rem(62px);
}
}
}
.timeline-content-box > * {
display: block;

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,3 +1,5 @@
<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>

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" }}
<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 }}
<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>
<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">
<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 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());
const showcaseItems = JSON.parse("{{ .ShowcaseTimelineJson }}");
initShowcaseContainer(document.querySelector('#showcase-container'), showcaseItems, 200);
</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>
</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));
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) {
for (const monthItems of itemsByMonth) {
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' });
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);
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,23 +1,24 @@
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
@ -27,179 +28,61 @@ type LandingTemplateData struct {
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,
numPosts, err := CountPosts(c.Context(), c.Conn, c.CurrentUser, PostsQuery{
ThreadTypes: feedThreadTypes,
})
if err != nil {
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,
})
c.Perf.EndBlock()
if err != nil {
c.Logger.Error().Err(err).Msg("failed to fetch project posts")
continue
c.Logger.Warn().Err(err).Msg("failed to fetch latest posts")
}
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
}
}
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)
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 {
@ -266,6 +154,12 @@ func Index(c *RequestContext) ResponseData {
var res ResponseData
err = res.WriteTemplate("landing.html", LandingTemplateData{
BaseData: baseData,
NewsPost: newsPostItem,
TimelineItems: timelineItems,
Pagination: pagination,
ShowcaseTimelineJson: showcaseJson,
FeedUrl: hmnurl.BuildFeed(),
PodcastUrl: hmnurl.BuildPodcast(models.HMNProjectSlug),
StreamsUrl: hmnurl.BuildStreams(),
@ -273,16 +167,8 @@ func Index(c *RequestContext) ResponseData {
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,
ShowcaseTimelineJson: showcaseJson,
AtomFeedUrl: hmnurl.BuildAtomFeed(),
MarkAllReadUrl: hmnurl.BuildForumMarkRead(models.HMNProjectSlug, 0),
WheelJamUrl: hmnurl.BuildJamIndex(),
}, c.Perf)
@ -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")