perf: add pagination to blog post list
removes the need for API controller accessed via JS
This commit is contained in:
parent
435cae95db
commit
435a69b27a
|
@ -1,4 +1,5 @@
|
||||||
using System.Diagnostics.CodeAnalysis;
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
using OliverBooth.Common.Data;
|
||||||
using OliverBooth.Common.Data.Blog;
|
using OliverBooth.Common.Data.Blog;
|
||||||
|
|
||||||
namespace OliverBooth.Common.Services;
|
namespace OliverBooth.Common.Services;
|
||||||
|
@ -22,8 +23,9 @@ public interface IBlogPostService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the total number of blog posts.
|
/// Returns the total number of blog posts.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <param name="visibility">The post visibility filter.</param>
|
||||||
/// <returns>The total number of blog posts.</returns>
|
/// <returns>The total number of blog posts.</returns>
|
||||||
int GetBlogPostCount();
|
int GetBlogPostCount(Visibility visibility = Visibility.None);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a collection of blog posts from the specified page, optionally limiting the number of posts
|
/// 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>
|
/// <returns>The next blog post from the specified blog post.</returns>
|
||||||
IBlogPost? GetNextPost(IBlogPost blogPost);
|
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>
|
/// <summary>
|
||||||
/// Returns the previous blog post from the specified blog post.
|
/// Returns the previous blog post from the specified blog post.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -49,7 +49,13 @@
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
|
<ProjectReference Include="..\OliverBooth.Common\OliverBooth.Common.csproj"/>
|
||||||
<ProjectReference Include="..\OliverBooth.Extensions.Markdig\OliverBooth.Extensions.Markdig.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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
@ -1,6 +1,9 @@
|
||||||
@page
|
@page
|
||||||
|
@using OliverBooth.Common.Data
|
||||||
|
@using OliverBooth.Common.Data.Blog
|
||||||
@using OliverBooth.Common.Services
|
@using OliverBooth.Common.Services
|
||||||
@model Index
|
@model Index
|
||||||
|
@inject IBlogPostService BlogPostService
|
||||||
|
|
||||||
@{
|
@{
|
||||||
ViewData["Title"] = "Blog";
|
ViewData["Title"] = "Blog";
|
||||||
|
@ -9,36 +12,15 @@
|
||||||
@await Html.PartialAsync("Partials/_MastodonStatus")
|
@await Html.PartialAsync("Partials/_MastodonStatus")
|
||||||
|
|
||||||
<div id="all-blog-posts">
|
<div id="all-blog-posts">
|
||||||
@await Html.PartialAsync("_LoadingSpinner")
|
@foreach (IBlogPost post in BlogPostService.GetBlogPosts(0))
|
||||||
|
{
|
||||||
|
@await Html.PartialAsync("Partials/_BlogCard", post)
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script id="blog-post-template" type="text/x-handlebars-template">
|
@await Html.PartialAsync("Partials/_PageTabs", new ViewDataDictionary(ViewData)
|
||||||
<div class="card-header">
|
{
|
||||||
<span class="text-muted">
|
["UrlRoot"] = "/blog",
|
||||||
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
|
["Page"] = 0,
|
||||||
<span>{{author.name}}<span>
|
["PageCount"] = BlogPostService.GetPageCount(visibility: Visibility.Published)
|
||||||
<span> • </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>
|
|
|
@ -36,7 +36,7 @@ public class Index : PageModel
|
||||||
return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
|
return _blogPostService.TryGetPost(wpPostId, out IBlogPost? post) ? RedirectToPost(post) : NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
private IActionResult RedirectToPost(IBlogPost post)
|
private RedirectResult RedirectToPost(IBlogPost post)
|
||||||
{
|
{
|
||||||
var route = new
|
var route = new
|
||||||
{
|
{
|
||||||
|
|
|
@ -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)
|
||||||
|
})
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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 = "≪";
|
||||||
|
|
||||||
|
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 = "<";
|
||||||
|
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 = ">";
|
||||||
|
|
||||||
|
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 = "≫";
|
||||||
|
|
||||||
|
listItem = document.CreateElement("li");
|
||||||
|
listItem.AddClass("page-item");
|
||||||
|
listItem.AppendChild(pageLink);
|
||||||
|
document.DocumentNode.AppendChild(listItem);
|
||||||
|
|
||||||
|
return document.DocumentNode.InnerHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum BoundsType
|
||||||
|
{
|
||||||
|
Lower,
|
||||||
|
Upper
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
•
|
||||||
|
<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>
|
|
@ -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>
|
|
@ -89,7 +89,6 @@ if (!app.Environment.IsDevelopment())
|
||||||
}
|
}
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
app.UseStatusCodePagesWithRedirects("/error/{0}");
|
|
||||||
app.UseStaticFiles();
|
app.UseStaticFiles();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
|
@ -36,10 +36,12 @@ internal sealed class BlogPostService : IBlogPostService
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public int GetBlogPostCount()
|
public int GetBlogPostCount(Visibility visibility = Visibility.None)
|
||||||
{
|
{
|
||||||
using BlogContext context = _dbContextFactory.CreateDbContext();
|
using BlogContext context = _dbContextFactory.CreateDbContext();
|
||||||
return context.BlogPosts.Count();
|
return visibility == Visibility.None
|
||||||
|
? context.BlogPosts.Count()
|
||||||
|
: context.BlogPosts.Count(p => p.Visibility == visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
@ -100,6 +102,13 @@ internal sealed class BlogPostService : IBlogPostService
|
||||||
.FirstOrDefault(post => post.Published > blogPost.Published);
|
.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 />
|
/// <inheritdoc />
|
||||||
public IBlogPost? GetPreviousPost(IBlogPost blogPost)
|
public IBlogPost? GetPreviousPost(IBlogPost blogPost)
|
||||||
{
|
{
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
@import "markdown";
|
@import "markdown";
|
||||||
|
@import "blog";
|
||||||
|
|
||||||
html, body {
|
html, body {
|
||||||
background: #121212;
|
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-"]) {
|
code:not([class*="language-"]) {
|
||||||
background: #1e1e1e !important;
|
background: #1e1e1e !important;
|
||||||
color: #dcdcda !important;
|
color: #dcdcda !important;
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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;
|
|
49
src/ts/UI.ts
49
src/ts/UI.ts
|
@ -1,5 +1,3 @@
|
||||||
import BlogPost from "./BlogPost";
|
|
||||||
import Author from "./Author";
|
|
||||||
import TimeUtility from "./TimeUtility";
|
import TimeUtility from "./TimeUtility";
|
||||||
|
|
||||||
declare const bootstrap: any;
|
declare const bootstrap: any;
|
||||||
|
@ -7,18 +5,6 @@ declare const katex: any;
|
||||||
declare const Prism: any;
|
declare const Prism: any;
|
||||||
|
|
||||||
class UI {
|
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.
|
* Creates a <script> element that loads the Disqus comment counter.
|
||||||
*/
|
*/
|
||||||
|
@ -30,41 +16,6 @@ class UI {
|
||||||
return script;
|
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.
|
* Forces all UI elements under the given element to update their rendering.
|
||||||
* @param element The element to search for UI elements in.
|
* @param element The element to search for UI elements in.
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
import API from "./API";
|
|
||||||
import UI from "./UI";
|
import UI from "./UI";
|
||||||
import Input from "./Input";
|
import Input from "./Input";
|
||||||
import Author from "./Author";
|
|
||||||
import BlogPost from "./BlogPost";
|
|
||||||
import Callout from "./Callout";
|
import Callout from "./Callout";
|
||||||
|
|
||||||
declare const Handlebars: any;
|
declare const Handlebars: any;
|
||||||
|
@ -35,72 +32,10 @@ declare const lucide: any;
|
||||||
|
|
||||||
Handlebars.registerHelper("urlEncode", encodeURIComponent);
|
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, () => {
|
Input.registerShortcut(Input.KONAMI_CODE, () => {
|
||||||
window.open("https://www.youtube.com/watch?v=dQw4w9WgXcQ", "_blank");
|
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();
|
UI.updateUI();
|
||||||
|
|
||||||
let avatarType = 0;
|
let avatarType = 0;
|
||||||
|
|
Loading…
Reference in New Issue