Rework the home page
This commit is contained in:
parent
7d422cb533
commit
f8d5f9fce5
|
@ -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();
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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%;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -249,6 +249,7 @@ func TimelineItemsToJSON(items []TimelineItem) string {
|
|||
builder := strings.Builder{}
|
||||
builder.WriteRune('[')
|
||||
for i, item := range items {
|
||||
|
||||
if i > 0 {
|
||||
builder.WriteRune(',')
|
||||
}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 }}
|
||||
— {{ 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 }}
|
||||
|
|
|
@ -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">✓</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 →
|
||||
</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> — {{ 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 →</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> — {{ timehtml (relativedate .Date) .Date }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="goto">
|
||||
<a href="{{ .Url }}">»</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 }}
|
||||
|
|
|
@ -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 }}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
Loading…
Reference in New Issue