perf: add dynamic fetch of blog posts to speed up page load
This commit is contained in:
parent
d11e3f616b
commit
95dd7e51e5
10
Gulpfile.js
10
Gulpfile.js
@ -4,6 +4,7 @@ const cleanCSS = require('gulp-clean-css');
|
||||
const rename = require('gulp-rename');
|
||||
const ts = require('gulp-typescript');
|
||||
const terser = require('gulp-terser');
|
||||
const webpack = require('webpack-stream');
|
||||
|
||||
const srcDir = 'src';
|
||||
const destDir = 'OliverBooth/wwwroot';
|
||||
@ -17,10 +18,13 @@ function compileSCSS() {
|
||||
}
|
||||
|
||||
function compileTS() {
|
||||
return gulp.src(`${srcDir}/ts/**/*.ts`)
|
||||
.pipe(ts())
|
||||
gulp.src(`${srcDir}/ts/**/*.ts`)
|
||||
.pipe(ts("tsconfig.json"))
|
||||
.pipe(terser())
|
||||
.pipe(rename({ suffix: '.min' }))
|
||||
.pipe(gulp.dest(`tmp/js`));
|
||||
|
||||
return gulp.src('tmp/js/*.js')
|
||||
.pipe(webpack({ mode: 'production', output: { filename: 'app.min.js' } }))
|
||||
.pipe(gulp.dest(`${destDir}/js`));
|
||||
}
|
||||
|
||||
|
@ -22,6 +22,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
src\ts\app.ts = src\ts\app.ts
|
||||
src\ts\prism.js = src\ts\prism.js
|
||||
src\ts\API.ts = src\ts\API.ts
|
||||
src\ts\BlogPost.ts = src\ts\BlogPost.ts
|
||||
src\ts\Author.ts = src\ts\Author.ts
|
||||
src\ts\TimeUtility.ts = src\ts\TimeUtility.ts
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Global
|
||||
|
70
OliverBooth/Controllers/BlogApiController.cs
Normal file
70
OliverBooth/Controllers/BlogApiController.cs
Normal file
@ -0,0 +1,70 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using OliverBooth.Data.Blog;
|
||||
using OliverBooth.Services;
|
||||
|
||||
namespace OliverBooth.Controllers;
|
||||
|
||||
[Controller]
|
||||
[Route("/api/blog")]
|
||||
public sealed class BlogApiController : ControllerBase
|
||||
{
|
||||
private readonly BlogService _blogService;
|
||||
|
||||
public BlogApiController(BlogService blogService)
|
||||
{
|
||||
_blogService = blogService;
|
||||
}
|
||||
|
||||
[Route("count")]
|
||||
public IActionResult Count()
|
||||
{
|
||||
return new JsonResult(new { count = _blogService.AllPosts.Count });
|
||||
}
|
||||
|
||||
[Route("all/{skip:int?}/{take:int?}")]
|
||||
public IActionResult GetAllBlogPosts(int skip = 0, int take = -1)
|
||||
{
|
||||
if (take == -1) take = _blogService.AllPosts.Count;
|
||||
|
||||
var referer = Request.Headers["Referer"].ToString();
|
||||
Console.WriteLine($"Referer: {referer}");
|
||||
if (!referer.StartsWith(Url.PageLink("/Blog/Index")!))
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return new JsonResult(_blogService.AllPosts.Skip(skip).Take(take).Select(post => new
|
||||
{
|
||||
id = post.Id,
|
||||
commentsEnabled = post.EnableComments,
|
||||
identifier = post.GetDisqusIdentifier(),
|
||||
author = post.AuthorId,
|
||||
title = post.Title,
|
||||
published = post.Published.ToUnixTimeSeconds(),
|
||||
updated = post.Updated?.ToUnixTimeSeconds(),
|
||||
excerpt = _blogService.GetExcerpt(post, out bool trimmed),
|
||||
trimmed,
|
||||
url = Url.Page("/Blog/Article",
|
||||
new
|
||||
{
|
||||
year = post.Published.Year,
|
||||
month = post.Published.Month,
|
||||
day = post.Published.Day,
|
||||
slug = post.Slug
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
[Route("author/{id:int}")]
|
||||
public IActionResult GetAuthor(int id)
|
||||
{
|
||||
if (!_blogService.TryGetAuthor(id, out Author? author)) return NotFound();
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
name = author.Name,
|
||||
avatarHash = author.AvatarHash,
|
||||
});
|
||||
}
|
||||
}
|
@ -5,7 +5,15 @@
|
||||
@model OliverBooth.Pages.Blog.Index
|
||||
@inject BlogService BlogService
|
||||
|
||||
@foreach (BlogPost post in BlogService.AllPosts)
|
||||
<div id="all_blog_posts">
|
||||
<div id="blog_loading_spinner" class="d-flex justify-content-center">
|
||||
<div class="spinner-border text-light" role="status">
|
||||
<p class="text-center sr-only">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@foreach (BlogPost post in ArraySegment<BlogPost>.Empty /*BlogService.AllPosts*/)
|
||||
{
|
||||
BlogService.TryGetAuthor(post, out Author? author);
|
||||
DateTimeOffset published = post.Published;
|
||||
|
@ -9,6 +9,7 @@
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/css/bootstrap.min.css" integrity="sha512-Z/def5z5u2aR89OuzYcxmDJ0Bnd5V1cKqBEbvLOiUNWdg9PQeXVvXLI90SE4QOHGlfLqUnDNVAYyZi8UwUTmWQ==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.css" integrity="sha512-7nTa5CnxbzfQgjQrNmHXB7bxGTUVO/DcYX6rpgt06MkzM0rVXP3EYCv/Ojxg5H0dKbY7llbbYaqgfZjnGOAWGA==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css" integrity="sha512-z3gLpd7yknf1YoNbCzqRKc4qyor8gaKU1qmn+CShxbuBusANI9QpRohGBreCFkKxLhei6S9CQXFEbbKuqLg0DA==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css" integrity="sha512-c42qTSw/wPZ3/5LBzD+Bw5f7bSF2oxou6wEb+I/lqeaKV5FDIfMvvRp772y4jcJLKuGUOpbJMdg/BTl50fJYAw==" crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;400;700&display=swap" rel="stylesheet">
|
||||
|
813
package-lock.json
generated
813
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,7 @@
|
||||
"gulp-terser": "^2.1.0",
|
||||
"gulp-typescript": "^6.0.0-alpha.1",
|
||||
"node-sass": "^9.0.0",
|
||||
"terser": "^5.19.2"
|
||||
"terser": "^5.19.2",
|
||||
"webpack-stream": "^7.0.0"
|
||||
}
|
||||
}
|
||||
|
@ -161,12 +161,12 @@ article {
|
||||
font-size: 32px;
|
||||
margin: 50px 0;
|
||||
}
|
||||
|
||||
|
||||
abbr {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted #ffffff;
|
||||
}
|
||||
|
||||
|
||||
span.timestamp {
|
||||
background: lighten(#333333, 12.5%);
|
||||
border-radius: 2px;
|
||||
@ -210,4 +210,29 @@ code[class*="language-"] {
|
||||
|
||||
div.alert *:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
#blog_loading_spinner {
|
||||
margin: 20px;
|
||||
|
||||
&.removed {
|
||||
animation: spinner-removed 2s ease-in-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes spinner-removed {
|
||||
0% {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scaleY(1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scaleY(0);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
27
src/ts/API.ts
Normal file
27
src/ts/API.ts
Normal file
@ -0,0 +1,27 @@
|
||||
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 fetch(`${API.BASE_URL + API.BLOG_URL}/count`);
|
||||
const text = await response.text();
|
||||
return JSON.parse(text).count;
|
||||
}
|
||||
|
||||
static async getBlogPosts(skip: number, take: number): Promise<BlogPost[]> {
|
||||
const response = await fetch(`${API.BASE_URL + API.BLOG_URL}/all/${skip}/${take}`);
|
||||
const text = await response.text();
|
||||
return JSON.parse(text).map(obj => new BlogPost(obj));
|
||||
}
|
||||
|
||||
static async getAuthor(id: number): Promise<Author> {
|
||||
const response = await fetch(`${API.BASE_URL + API.BLOG_URL}/author/${id}`);
|
||||
const text = await response.text();
|
||||
return new Author(JSON.parse(text));
|
||||
}
|
||||
}
|
||||
|
||||
export default API;
|
18
src/ts/Author.ts
Normal file
18
src/ts/Author.ts
Normal file
@ -0,0 +1,18 @@
|
||||
class Author {
|
||||
private _name: string;
|
||||
private _avatarHash: string;
|
||||
|
||||
constructor(json: any) {
|
||||
this._name = json.name;
|
||||
this._avatarHash = json.avatarHash;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this._name;
|
||||
}
|
||||
|
||||
get avatarHash(): string {
|
||||
return this._avatarHash;
|
||||
}
|
||||
}
|
||||
export default Author;
|
67
src/ts/BlogPost.ts
Normal file
67
src/ts/BlogPost.ts
Normal file
@ -0,0 +1,67 @@
|
||||
class BlogPost {
|
||||
private readonly _id: number;
|
||||
private readonly _commentsEnabled: boolean;
|
||||
private readonly _title: string;
|
||||
private readonly _excerpt: string;
|
||||
private readonly _authorId: number;
|
||||
private readonly _published: Date;
|
||||
private readonly _updated?: Date;
|
||||
private readonly _url: string;
|
||||
private readonly _trimmed: boolean;
|
||||
private readonly _identifier: string;
|
||||
|
||||
constructor(json: any) {
|
||||
this._id = json.id;
|
||||
this._commentsEnabled = json.commentsEnabled;
|
||||
this._title = json.title;
|
||||
this._excerpt = json.excerpt;
|
||||
this._authorId = parseInt(json.author);
|
||||
this._published = new Date(json.published * 1000);
|
||||
this._updated = (json.updated && new Date(json.updated * 1000)) || null;
|
||||
this._url = json.url;
|
||||
this._trimmed = json.trimmed;
|
||||
this._identifier = json.identifier;
|
||||
}
|
||||
|
||||
get id(): number {
|
||||
return this._id;
|
||||
}
|
||||
|
||||
get commentsEnabled(): boolean {
|
||||
return this._commentsEnabled;
|
||||
}
|
||||
|
||||
get title(): string {
|
||||
return this._title;
|
||||
}
|
||||
|
||||
get excerpt(): string {
|
||||
return this._excerpt;
|
||||
}
|
||||
|
||||
get authorId(): number {
|
||||
return this._authorId;
|
||||
}
|
||||
|
||||
get published(): Date {
|
||||
return this._published;
|
||||
}
|
||||
|
||||
get updated(): Date {
|
||||
return this._updated;
|
||||
}
|
||||
|
||||
get url(): string {
|
||||
return this._url;
|
||||
}
|
||||
|
||||
get trimmed(): boolean {
|
||||
return this._trimmed;
|
||||
}
|
||||
|
||||
get identifier(): string {
|
||||
return this._identifier;
|
||||
}
|
||||
}
|
||||
|
||||
export default BlogPost;
|
38
src/ts/TimeUtility.ts
Normal file
38
src/ts/TimeUtility.ts
Normal file
@ -0,0 +1,38 @@
|
||||
class TimeUtility {
|
||||
public static formatRelativeTimestamp(timestamp: Date) {
|
||||
const now = new Date();
|
||||
// @ts-ignore
|
||||
const diff = now - timestamp;
|
||||
const suffix = diff < 0 ? 'from now' : 'ago';
|
||||
|
||||
const seconds = Math.floor(diff / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds} second${seconds !== 1 ? 's' : ''} ${suffix}`;
|
||||
}
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''} ${suffix}`;
|
||||
}
|
||||
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
if (hours < 24) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ${suffix}`;
|
||||
}
|
||||
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days < 30) {
|
||||
return `${days} day${days !== 1 ? 's' : ''} ${suffix}`;
|
||||
}
|
||||
|
||||
const months = Math.floor(diff / 2592000000);
|
||||
if (months < 12) {
|
||||
return `${months} month${months !== 1 ? 's' : ''} ${suffix}`;
|
||||
}
|
||||
|
||||
const years = Math.floor(diff / 31536000000);
|
||||
return `${years} year${years !== 1 ? 's' : ''} ${suffix}`;
|
||||
};
|
||||
}
|
||||
|
||||
export default TimeUtility;
|
@ -1,7 +1,102 @@
|
||||
import API from "./API";
|
||||
import TimeUtility from "./TimeUtility";
|
||||
|
||||
declare const bootstrap: any;
|
||||
declare const katex: any;
|
||||
|
||||
(() => {
|
||||
const blogPostContainer = document.querySelector("#all_blog_posts");
|
||||
if (blogPostContainer) {
|
||||
API.getBlogPostCount().then(async (count) => {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const posts = await API.getBlogPosts(i, 5);
|
||||
for (const post of posts) {
|
||||
const author = await API.getAuthor(post.authorId);
|
||||
|
||||
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 cardBody = document.createElement("div");
|
||||
cardBody.classList.add("card-body");
|
||||
card.appendChild(cardBody);
|
||||
|
||||
const postTitle = document.createElement("h2");
|
||||
postTitle.classList.add("card-title");
|
||||
cardBody.appendChild(postTitle);
|
||||
|
||||
const titleLink = document.createElement("a");
|
||||
titleLink.href = post.url;
|
||||
titleLink.innerText = post.title;
|
||||
postTitle.appendChild(titleLink);
|
||||
|
||||
const metadata = document.createElement("p");
|
||||
metadata.classList.add("text-muted");
|
||||
cardBody.appendChild(metadata);
|
||||
|
||||
const authorIcon = document.createElement("img");
|
||||
authorIcon.classList.add("blog-author-icon");
|
||||
authorIcon.src = `https://gravatar.com/avatar/${author.avatarHash}?s=28`;
|
||||
authorIcon.alt = author.name;
|
||||
metadata.appendChild(authorIcon);
|
||||
|
||||
const authorName = document.createElement("span");
|
||||
authorName.innerHTML = ` ${author.name} • `;
|
||||
metadata.appendChild(authorName);
|
||||
|
||||
const postDate = document.createElement("span");
|
||||
if (post.updated) {
|
||||
postDate.innerHTML = `Updated ${TimeUtility.formatRelativeTimestamp(post.updated)}`;
|
||||
} else {
|
||||
postDate.innerHTML = `Published ${TimeUtility.formatRelativeTimestamp(post.published)}`;
|
||||
}
|
||||
metadata.appendChild(postDate);
|
||||
|
||||
if (post.commentsEnabled) {
|
||||
const bullet = document.createElement("span");
|
||||
bullet.innerHTML = " • ";
|
||||
metadata.appendChild(bullet);
|
||||
|
||||
const commentCount = document.createElement("a");
|
||||
commentCount.href = post.url + "#disqus_thread";
|
||||
commentCount.innerHTML = "0 Comments";
|
||||
commentCount.setAttribute("data-disqus-identifier", post.identifier);
|
||||
metadata.appendChild(commentCount);
|
||||
}
|
||||
|
||||
const postExcerpt = document.createElement("p");
|
||||
postExcerpt.innerHTML = post.excerpt;
|
||||
cardBody.appendChild(postExcerpt);
|
||||
|
||||
if (post.trimmed) {
|
||||
const readMoreLink = document.createElement("a");
|
||||
readMoreLink.href = post.url;
|
||||
readMoreLink.innerHTML = "Read more …";
|
||||
cardBody.appendChild(readMoreLink);
|
||||
}
|
||||
|
||||
blogPostContainer.appendChild(card);
|
||||
}
|
||||
|
||||
i += 4;
|
||||
}
|
||||
|
||||
const disqusCounter = document.createElement("script");
|
||||
disqusCounter.id = "dsq-count-scr";
|
||||
disqusCounter.src = "https://oliverbooth-dev.disqus.com/count.js";
|
||||
disqusCounter.async = true;
|
||||
|
||||
const spinner = document.querySelector("#blog_loading_spinner");
|
||||
if (spinner) {
|
||||
spinner.classList.add("removed");
|
||||
setTimeout(() => spinner.remove(), 1100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const formatRelativeTime = function (timestamp) {
|
||||
const now = new Date();
|
||||
// @ts-ignore
|
||||
|
Loading…
Reference in New Issue
Block a user