perf: add pagination to blog post list

removes the need for API controller accessed via JS
This commit is contained in:
Oliver Booth 2024-05-05 18:13:06 +01:00
parent 435cae95db
commit 435a69b27a
Signed by: oliverbooth
GPG Key ID: E60B570D1B7557B5
20 changed files with 431 additions and 466 deletions

View File

@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using OliverBooth.Common.Data;
using OliverBooth.Common.Data.Blog;
namespace OliverBooth.Common.Services;
@ -22,8 +23,9 @@ public interface IBlogPostService
/// <summary>
/// Returns the total number of blog posts.
/// </summary>
/// <param name="visibility">The post visibility filter.</param>
/// <returns>The total number of blog posts.</returns>
int GetBlogPostCount();
int GetBlogPostCount(Visibility visibility = Visibility.None);
/// <summary>
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
@ -62,6 +64,15 @@ public interface IBlogPostService
/// <returns>The next blog post from the specified blog post.</returns>
IBlogPost? GetNextPost(IBlogPost blogPost);
/// <summary>
/// Returns the number of pages needed to render all blog posts, using the specified <paramref name="pageSize" /> as an
/// indicator of how many posts are allowed per page.
/// </summary>
/// <param name="pageSize">The page size. Defaults to 10.</param>
/// <param name="visibility">The post visibility filter.</param>
/// <returns>The page count.</returns>
int GetPageCount(int pageSize = 10, Visibility visibility = Visibility.None);
/// <summary>
/// Returns the previous blog post from the specified blog post.
/// </summary>

View File

@ -1,102 +0,0 @@
using Humanizer;
using Microsoft.AspNetCore.Mvc;
using OliverBooth.Common.Data.Blog;
using OliverBooth.Common.Services;
namespace OliverBooth.Controllers.Blog;
/// <summary>
/// Represents a controller for the blog API.
/// </summary>
[ApiController]
[Route("api/blog")]
[Produces("application/json")]
public sealed class BlogApiController : ControllerBase
{
private readonly IBlogPostService _blogPostService;
private readonly IBlogUserService _userService;
/// <summary>
/// Initializes a new instance of the <see cref="BlogApiController" /> class.
/// </summary>
/// <param name="blogPostService">The <see cref="IBlogPostService" />.</param>
/// <param name="userService">The <see cref="IBlogUserService" />.</param>
public BlogApiController(IBlogPostService blogPostService, IBlogUserService userService)
{
_blogPostService = blogPostService;
_userService = userService;
}
[Route("count")]
public IActionResult Count()
{
return Ok(new { count = _blogPostService.GetBlogPostCount() });
}
[HttpGet("posts/{page:int?}")]
public IActionResult GetAllBlogPosts(int page = 0)
{
const int itemsPerPage = 10;
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
[HttpGet("posts/tagged/{tag}/{page:int?}")]
public IActionResult GetTaggedBlogPosts(string tag, int page = 0)
{
const int itemsPerPage = 10;
tag = tag.Replace('-', ' ').ToLowerInvariant();
IReadOnlyList<IBlogPost> allPosts = _blogPostService.GetBlogPosts(page, itemsPerPage);
allPosts = allPosts.Where(post => post.Tags.Contains(tag)).ToList();
return Ok(allPosts.Select(post => CreatePostObject(post)));
}
[HttpGet("author/{id:guid}")]
public IActionResult GetAuthor(Guid id)
{
if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
return Ok(new
{
id = author.Id,
name = author.DisplayName,
avatarUrl = author.AvatarUrl,
});
}
[HttpGet("post/{id:guid?}")]
public IActionResult GetPost(Guid id)
{
if (!_blogPostService.TryGetPost(id, out IBlogPost? post)) return NotFound();
return Ok(CreatePostObject(post, true));
}
private object CreatePostObject(IBlogPost post, bool includeContent = false)
{
return new
{
id = post.Id,
commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(),
author = post.Author.Id,
title = post.Title,
published = post.Published.ToUnixTimeSeconds(),
updated = post.Updated?.ToUnixTimeSeconds(),
formattedPublishDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
formattedUpdateDate = post.Updated?.ToString("dddd, d MMMM yyyy HH:mm"),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
content = includeContent ? _blogPostService.RenderPost(post) : null,
trimmed,
tags = post.Tags.Select(t => t.Replace(' ', '-')),
url = new
{
year = post.Published.ToString("yyyy"),
month = post.Published.ToString("MM"),
day = post.Published.ToString("dd"),
slug = post.Slug
}
};
}
}

View File

@ -49,7 +49,13 @@
<ItemGroup>
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
<ProjectReference Include="..\OliverBooth.Extensions.Markdig\OliverBooth.Extensions.Markdig.csproj"/>
<ProjectReference Include="..\OliverBooth.Extensions.SmartFormat\OliverBooth.Extensions.SmartFormat.csproj" />
<ProjectReference Include="..\OliverBooth.Extensions.SmartFormat\OliverBooth.Extensions.SmartFormat.csproj"/>
</ItemGroup>
<ItemGroup>
<Compile Update="Pages\Shared\Partials\PageTabsUtility.cs">
<DependentUpon>_PageTabs.cshtml</DependentUpon>
</Compile>
</ItemGroup>
</Project>

View File

@ -1,6 +1,9 @@
@page
@using OliverBooth.Common.Data
@using OliverBooth.Common.Data.Blog
@using OliverBooth.Common.Services
@model Index
@inject IBlogPostService BlogPostService
@{
ViewData["Title"] = "Blog";
@ -9,36 +12,15 @@
@await Html.PartialAsync("Partials/_MastodonStatus")
<div id="all-blog-posts">
@await Html.PartialAsync("_LoadingSpinner")
@foreach (IBlogPost post in BlogPostService.GetBlogPosts(0))
{
@await Html.PartialAsync("Partials/_BlogCard", post)
}
</div>
<script id="blog-post-template" type="text/x-handlebars-template">
<div class="card-header">
<span class="text-muted">
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
<span>{{author.name}}<span>
<span> &bull; </span>
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
</span>
</div>
<div class="card-body">
<h2>
<a href="{{post.url}}"> {{post.title}}</a>
</h2>
<p>{{{post.excerpt}}}</p>
{{#if post.trimmed}}
<p>
<a href="{{post.url}}">
Read more...
</a>
</p>
{{/if}}
</div>
<div class="card-footer">
{{#each post.tags}}
<a href="?tag={{urlEncode this}}" class="badge text-bg-dark">{{this}}</a>
{{/each}}
</div>
</script>
@await Html.PartialAsync("Partials/_PageTabs", new ViewDataDictionary(ViewData)
{
["UrlRoot"] = "/blog",
["Page"] = 0,
["PageCount"] = BlogPostService.GetPageCount(visibility: Visibility.Published)
})

View File

@ -36,7 +36,7 @@ public class Index : PageModel
return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
}
private IActionResult RedirectToPost(IBlogPost post)
private RedirectResult RedirectToPost(IBlogPost post)
{
var route = new
{

View File

@ -0,0 +1,23 @@
@page "/blog/page/{pageNumber:int}"
@model List
@using OliverBooth.Common.Data
@using OliverBooth.Common.Data.Blog
@using OliverBooth.Common.Services
@inject IBlogPostService BlogPostService
@await Html.PartialAsync("Partials/_MastodonStatus")
<div id="all-blog-posts">
@foreach (IBlogPost post in BlogPostService.GetBlogPosts(Model.PageNumber))
{
@await Html.PartialAsync("Partials/_BlogCard", post)
}
</div>
@await Html.PartialAsync("Partials/_PageTabs", new ViewDataDictionary(ViewData)
{
["UrlRoot"] = "/blog",
["Page"] = Model.PageNumber,
["PageCount"] = BlogPostService.GetPageCount(visibility: Visibility.Published)
})

View File

@ -0,0 +1,32 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace OliverBooth.Pages.Blog;
/// <summary>
/// Represents a class which defines the model for the <c>/blog/page/#</c> route.
/// </summary>
public class List : PageModel
{
/// <summary>
/// Gets the requested page number.
/// </summary>
/// <value>The requested page number.</value>
public int PageNumber { get; private set; }
/// <summary>
/// Handles the incoming GET request to the page.
/// </summary>
/// <param name="page">The requested page number, starting from 1.</param>
/// <returns></returns>
public IActionResult OnGet([FromRoute(Name = "pageNumber")] int page = 1)
{
if (page < 2)
{
return RedirectToPage("Index");
}
PageNumber = page;
return Page();
}
}

View File

@ -0,0 +1,205 @@
using Cysharp.Text;
using HtmlAgilityPack;
namespace OliverBooth.Pages.Shared.Partials;
/// <summary>
/// Provides methods for displaying pagination tabs.
/// </summary>
public class PageTabsUtility
{
private string _urlRoot = string.Empty;
/// <summary>
/// Gets or sets the current page number.
/// </summary>
/// <value>The current page number.</value>
public int CurrentPage { get; set; } = 1;
/// <summary>
/// Gets or sets the page count.
/// </summary>
/// <value>The page count.</value>
public int PageCount { get; set; } = 1;
/// <summary>
/// Gets or sets the URL root.
/// </summary>
/// <value>The URL root.</value>
public string UrlRoot
{
get => _urlRoot;
set => _urlRoot = string.IsNullOrWhiteSpace(value) ? string.Empty : value.Trim();
}
/// <summary>
/// Shows the bound chevrons for the specified bounds type.
/// </summary>
/// <param name="bounds">The bounds type to display.</param>
/// <returns>An HTML string containing the elements representing the bound chevrons.</returns>
public string ShowBounds(BoundsType bounds)
{
return bounds switch
{
BoundsType.Lower => ShowLowerBound(),
BoundsType.Upper => ShowUpperBound(PageCount),
_ => string.Empty
};
}
/// <summary>
/// Shows the specified page tab.
/// </summary>
/// <param name="tab">The tab to display.</param>
/// <returns>An HTML string containing the element for the specified page tab.</returns>
public string ShowTab(int tab)
{
var document = new HtmlDocument();
HtmlNode listItem = document.CreateElement("li");
HtmlNode pageLink;
listItem.AddClass("page-item");
switch (tab)
{
case 0:
// show ... to indicate truncation
pageLink = document.CreateElement("span");
pageLink.InnerHtml = "...";
break;
case var _ when CurrentPage == tab:
listItem.AddClass("active");
listItem.SetAttributeValue("aria-current", "page");
pageLink = document.CreateElement("span");
pageLink.InnerHtml = tab.ToString();
break;
default:
pageLink = document.CreateElement("a");
pageLink.SetAttributeValue("href", GetLinkForPage(tab));
pageLink.InnerHtml = tab.ToString();
break;
}
pageLink.AddClass("page-link");
listItem.AppendChild(pageLink);
document.DocumentNode.AppendChild(listItem);
return document.DocumentNode.InnerHtml;
}
/// <summary>
/// Shows the paginated tab window.
/// </summary>
/// <returns>An HTML string representing the page tabs.</returns>
public string ShowTabWindow()
{
using Utf16ValueStringBuilder builder = ZString.CreateStringBuilder();
int windowLowerBound = Math.Max(CurrentPage - 2, 1);
int windowUpperBound = Math.Min(CurrentPage + 2, PageCount);
if (windowLowerBound > 2)
{
// show lower truncation ...
builder.AppendLine(ShowTab(0));
}
for (int pageIndex = windowLowerBound; pageIndex <= windowUpperBound; pageIndex++)
{
if (pageIndex == 1 || pageIndex == PageCount)
{
// don't show bounds, these are explicitly written
continue;
}
builder.AppendLine(ShowTab(pageIndex));
}
if (windowUpperBound < PageCount - 1)
{
// show upper truncation ...
builder.AppendLine(ShowTab(0));
}
return builder.ToString();
}
private string GetLinkForPage(int page)
{
// page 1 doesn't use /page/n route
return page == 1 ? _urlRoot : $"{_urlRoot}/page/{page}";
}
private string ShowLowerBound()
{
if (CurrentPage <= 1)
{
return string.Empty;
}
var document = new HtmlDocument();
HtmlNode listItem = document.CreateElement("li");
listItem.AddClass("page-item");
HtmlNode pageLink = document.CreateElement("a");
listItem.AppendChild(pageLink);
pageLink.AddClass("page-link");
pageLink.SetAttributeValue("href", UrlRoot);
pageLink.InnerHtml = "&Lt;";
document.DocumentNode.AppendChild(listItem);
listItem = document.CreateElement("li");
listItem.AddClass("page-item");
pageLink = document.CreateElement("a");
listItem.AppendChild(pageLink);
pageLink.AddClass("page-link");
pageLink.InnerHtml = "&lt;";
pageLink.SetAttributeValue("href", GetLinkForPage(CurrentPage - 1));
document.DocumentNode.AppendChild(listItem);
return document.DocumentNode.InnerHtml;
}
private string ShowUpperBound(int pageCount)
{
if (CurrentPage >= pageCount)
{
return string.Empty;
}
var document = new HtmlDocument();
HtmlNode pageLink = document.CreateElement("a");
pageLink.AddClass("page-link");
pageLink.SetAttributeValue("href", GetLinkForPage(CurrentPage + 1));
pageLink.InnerHtml = "&gt;";
HtmlNode listItem = document.CreateElement("li");
listItem.AddClass("page-item");
listItem.AppendChild(pageLink);
document.DocumentNode.AppendChild(listItem);
pageLink = document.CreateElement("a");
pageLink.AddClass("page-link");
pageLink.SetAttributeValue("href", GetLinkForPage(pageCount));
pageLink.InnerHtml = "&Gt;";
listItem = document.CreateElement("li");
listItem.AddClass("page-item");
listItem.AppendChild(pageLink);
document.DocumentNode.AppendChild(listItem);
return document.DocumentNode.InnerHtml;
}
public enum BoundsType
{
Lower,
Upper
}
}

View File

@ -0,0 +1,56 @@
@using Humanizer
@using OliverBooth.Common.Data.Blog
@using OliverBooth.Common.Services
@model IBlogPost
@inject IBlogPostService BlogPostService
@{
IBlogAuthor author = Model.Author;
DateTimeOffset published = Model.Published;
DateTimeOffset? updated = Model.Updated;
DateTimeOffset time = updated ?? published;
string verb = updated is null ? "Published" : "Updated";
}
<div class="blog-card">
<h4>
<a asp-page="/Blog/Article"
asp-route-year="@published.Year"
asp-route-month="@published.Month.ToString("00")"
asp-route-day="@published.Day.ToString("00")"
asp-route-slug="@Model.Slug">
@Model.Title
</a>
</h4>
<p>
<img class="blog-author-icon" src="@author.GetAvatarUrl()" alt="@author.DisplayName">
@author.DisplayName
&bull;
<span class="text-muted" title="@time.ToString("F")">@verb @time.Humanize()</span>
</p>
<article>
@Html.Raw(BlogPostService.RenderExcerpt(Model, out bool trimmed))
</article>
@if (trimmed || Model.Excerpt is not null)
{
<p>
<a asp-page="/Blog/Article"
asp-route-year="@published.Year"
asp-route-month="@published.Month.ToString("00")"
asp-route-day="@published.Day.ToString("00")"
asp-route-slug="@Model.Slug">
Read more...
</a>
</p>
}
<hr/>
@foreach (string tag in Model.Tags)
{
<a href="?tag=@Html.UrlEncoder.Encode(tag)" class="badge text-bg-dark">@tag</a>
}
</div>

View File

@ -0,0 +1,21 @@
@{
var urlRoot = ViewData["UrlRoot"]?.ToString() ?? string.Empty;
var page = (int)(ViewData["Page"] ?? 1);
var pageCount = (int)(ViewData["PageCount"] ?? 1);
var utility = new PageTabsUtility
{
CurrentPage = page,
PageCount = pageCount,
UrlRoot = urlRoot
};
}
<nav>
<ul class="pagination justify-content-center">
@Html.Raw(utility.ShowBounds(PageTabsUtility.BoundsType.Lower))
@Html.Raw(utility.ShowTab(1)) @* always visible *@
@Html.Raw(utility.ShowTabWindow())
@Html.Raw(utility.ShowTab(pageCount)) @* always visible *@
@Html.Raw(utility.ShowBounds(PageTabsUtility.BoundsType.Upper))
</ul>
</nav>

View File

@ -89,7 +89,6 @@ if (!app.Environment.IsDevelopment())
}
app.UseHttpsRedirection();
app.UseStatusCodePagesWithRedirects("/error/{0}");
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();

View File

@ -36,10 +36,12 @@ internal sealed class BlogPostService : IBlogPostService
}
/// <inheritdoc />
public int GetBlogPostCount()
public int GetBlogPostCount(Visibility visibility = Visibility.None)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
return context.BlogPosts.Count();
return visibility == Visibility.None
? context.BlogPosts.Count()
: context.BlogPosts.Count(p => p.Visibility == visibility);
}
/// <inheritdoc />
@ -100,6 +102,13 @@ internal sealed class BlogPostService : IBlogPostService
.FirstOrDefault(post => post.Published > blogPost.Published);
}
/// <inheritdoc />
public int GetPageCount(int pageSize = 10, Visibility visibility = Visibility.None)
{
float postCount = GetBlogPostCount(visibility);
return (int)MathF.Ceiling(postCount / pageSize);
}
/// <inheritdoc />
public IBlogPost? GetPreviousPost(IBlogPost blogPost)
{

View File

@ -1,4 +1,5 @@
@import "markdown";
@import "blog";
html, body {
background: #121212;
@ -229,19 +230,6 @@ article {
}
}
.blog-card {
transition: all 0.2s ease-in-out;
&:hover {
transform: scale(1.05);
}
article {
background: none;
padding: 0;
}
}
code:not([class*="language-"]) {
background: #1e1e1e !important;
color: #dcdcda !important;

49
src/scss/blog.scss Normal file
View File

@ -0,0 +1,49 @@
$blog-card-bg: #333333;
$blog-card-gutter: 20px;
$border-radius: 3px;
div.blog-card {
background: $blog-card-bg;
margin-bottom: $blog-card-gutter;
padding: $blog-card-gutter;
border-radius: $border-radius;
:last-child {
margin-bottom: 0;
}
article {
padding: 0;
margin: 0;
}
}
ul.pagination {
border: none;
li {
a, span {
border-radius: $border-radius !important;
border: none;
&:link, &:visited, &:active {
color: #007ec6;
}
}
&.active a, &.active span {
background: #007ec6;
}
&:not(.active) a, &:not(.active) span {
background: none;
}
&:hover {
a {
color: #ffffff !important;
}
}
}
}

View File

@ -1,44 +0,0 @@
import BlogPost from "./BlogPost";
import Author from "./Author";
class API {
private static readonly BASE_URL: string = "/api";
private static readonly BLOG_URL: string = "/blog";
static async getBlogPostCount(): Promise<number> {
const response = await API.getResponse(`count`);
return response.count;
}
static async getBlogPost(id: string): Promise<BlogPost> {
const response = await API.getResponse(`post/${id}`);
return new BlogPost(response);
}
static async getBlogPosts(page: number): Promise<BlogPost[]> {
const response = await API.getResponse(`posts/${page}`);
return response.map(obj => new BlogPost(obj));
}
static async getBlogPostsByTag(tag: string, page: number): Promise<BlogPost[]> {
const response = await API.getResponse(`posts/tagged/${tag}/${page}`);
return response.map(obj => new BlogPost(obj));
}
static async getAuthor(id: string): Promise<Author> {
const response = await API.getResponse(`author/${id}`);
return new Author(response);
}
private static async getResponse(url: string): Promise<any> {
const response = await fetch(`${API.BASE_URL + API.BLOG_URL}/${url}`);
if (response.status !== 200) {
throw new Error("Invalid response from server");
}
const text = await response.text();
return JSON.parse(text);
}
}
export default API;

View File

@ -1,25 +0,0 @@
class Author {
private readonly _id: string;
private readonly _name: string;
private readonly _avatarUrl: string;
constructor(json: any) {
this._id = json.id;
this._name = json.name;
this._avatarUrl = json.avatarUrl;
}
get id(): string {
return this._id;
}
get name(): string {
return this._name;
}
get avatarUrl(): string {
return this._avatarUrl;
}
}
export default Author;

View File

@ -1,99 +0,0 @@
import BlogUrl from "./BlogUrl";
class BlogPost {
private readonly _id: string;
private readonly _commentsEnabled: boolean;
private readonly _title: string;
private readonly _excerpt: string;
private readonly _content: string;
private readonly _authorId: string;
private readonly _published: Date;
private readonly _updated?: Date;
private readonly _url: BlogUrl;
private readonly _trimmed: boolean;
private readonly _identifier: string;
private readonly _humanizedTimestamp: string;
private readonly _formattedPublishDate: string;
private readonly _formattedUpdateDate: string;
private readonly _tags: string[];
constructor(json: any) {
this._id = json.id;
this._commentsEnabled = json.commentsEnabled;
this._title = json.title;
this._excerpt = json.excerpt;
this._content = json.content;
this._authorId = json.author;
this._published = new Date(json.published * 1000);
this._updated = (json.updated && new Date(json.updated * 1000)) || null;
this._url = new BlogUrl(json.url);
this._trimmed = json.trimmed;
this._identifier = json.identifier;
this._humanizedTimestamp = json.humanizedTimestamp;
this._formattedPublishDate = json.formattedPublishDate;
this._formattedUpdateDate = json.formattedUpdateDate;
this._tags = json.tags;
}
get id(): string {
return this._id;
}
get commentsEnabled(): boolean {
return this._commentsEnabled;
}
get title(): string {
return this._title;
}
get excerpt(): string {
return this._excerpt;
}
get content(): string {
return this._content;
}
get authorId(): string {
return this._authorId;
}
get published(): Date {
return this._published;
}
get updated(): Date {
return this._updated;
}
get url(): BlogUrl {
return this._url;
}
get tags(): string[] {
return this._tags;
}
get trimmed(): boolean {
return this._trimmed;
}
get identifier(): string {
return this._identifier;
}
get humanizedTimestamp(): string {
return this._humanizedTimestamp;
}
get formattedPublishDate(): string {
return this._formattedPublishDate;
}
get formattedUpdateDate(): string {
return this._formattedUpdateDate;
}
}
export default BlogPost;

View File

@ -1,32 +0,0 @@
class BlogUrl {
private readonly _year: string;
private readonly _month: string;
private readonly _day: string;
private readonly _slug: string;
constructor(json: any) {
this._year = json.year;
this._month = json.month;
this._day = json.day;
this._slug = json.slug;
}
get year(): string {
return this._year;
}
get month(): string {
return this._month;
}
get day(): string {
return this._day;
}
get slug(): string {
return this._slug;
}
}
export default BlogUrl;

View File

@ -1,5 +1,3 @@
import BlogPost from "./BlogPost";
import Author from "./Author";
import TimeUtility from "./TimeUtility";
declare const bootstrap: any;
@ -7,18 +5,6 @@ declare const katex: any;
declare const Prism: any;
class UI {
public static get blogPost(): HTMLDivElement {
return document.querySelector("article[data-blog-post='true']");
}
public static get blogPostContainer(): HTMLDivElement {
return document.querySelector("#all-blog-posts");
}
public static get blogPostTemplate(): HTMLDivElement {
return document.querySelector("#blog-post-template");
}
/**
* Creates a <script> element that loads the Disqus comment counter.
*/
@ -30,41 +16,6 @@ class UI {
return script;
}
/**
* Creates a blog post card from the given template, post, and author.
* @param template The Handlebars template to use.
* @param post The blog post to use.
* @param author The author of the blog post.
*/
public static createBlogPostCard(template: any, post: BlogPost, author: Author): HTMLDivElement {
const card = document.createElement("div") as HTMLDivElement;
card.classList.add("card");
card.classList.add("blog-card");
card.classList.add("animate__animated");
card.classList.add("animate__fadeIn");
card.style.marginBottom = "50px";
const body = template({
post: {
title: post.title,
excerpt: post.excerpt,
url: `/blog/${post.url.year}/${post.url.month}/${post.url.day}/${post.url.slug}`,
date: TimeUtility.formatRelativeTimestamp(post.published),
formattedDate: post.updated ? post.formattedUpdateDate : post.formattedPublishDate,
date_humanized: `${post.updated ? "Updated" : "Published"} ${post.humanizedTimestamp}`,
enable_comments: post.commentsEnabled,
trimmed: post.trimmed,
tags: post.tags
},
author: {
name: author.name,
avatar: author.avatarUrl
}
});
card.innerHTML = body.trim();
return card;
}
/**
* Forces all UI elements under the given element to update their rendering.
* @param element The element to search for UI elements in.

View File

@ -1,8 +1,5 @@
import API from "./API";
import UI from "./UI";
import Input from "./Input";
import Author from "./Author";
import BlogPost from "./BlogPost";
import Callout from "./Callout";
declare const Handlebars: any;
@ -35,72 +32,10 @@ declare const lucide: any;
Handlebars.registerHelper("urlEncode", encodeURIComponent);
function getQueryVariable(variable: string): string {
const query = window.location.search.substring(1);
const vars = query.split("&");
for (const element of vars) {
const pair = element.split("=");
if (pair[0] == variable) {
return pair[1];
}
}
return null;
}
Input.registerShortcut(Input.KONAMI_CODE, () => {
window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
});
const blogPost = UI.blogPost;
if (blogPost) {
const id = blogPost.dataset.blogId;
API.getBlogPost(id).then((post) => {
blogPost.innerHTML = post.content;
UI.updateUI(blogPost);
});
}
const blogPostContainer = UI.blogPostContainer;
if (blogPostContainer) {
const authors = [];
const template = Handlebars.compile(UI.blogPostTemplate.innerHTML);
API.getBlogPostCount().then(async (count) => {
for (let i = 0; i <= count / 10; i++) {
let posts: BlogPost[];
const tag = getQueryVariable("tag");
if (tag !== null && tag !== "") {
posts = await API.getBlogPostsByTag(decodeURIComponent(tag), i);
} else {
posts = await API.getBlogPosts(i);
}
for (const post of posts) {
let author: Author;
if (authors[post.authorId]) {
author = authors[post.authorId];
} else {
author = await API.getAuthor(post.authorId);
authors[post.authorId] = author;
}
const card = UI.createBlogPostCard(template, post, author);
blogPostContainer.appendChild(card);
UI.updateUI(card);
}
}
document.body.appendChild(UI.createDisqusCounterScript());
const spinner = document.querySelector("#blog-loading-spinner");
if (spinner) {
spinner.classList.add("removed");
setTimeout(() => spinner.remove(), 1000);
}
});
}
UI.updateUI();
let avatarType = 0;