Compare commits

..

12 Commits

13 changed files with 99 additions and 130 deletions

1
.gitignore vendored
View File

@ -4,6 +4,7 @@ project.lock.json
.DS_Store .DS_Store
*.pyc *.pyc
nupkg/ nupkg/
tmp/
# Visual Studio Code # Visual Studio Code
.vscode .vscode

View File

@ -18,11 +18,13 @@ function compileSCSS() {
} }
function compileTS() { function compileTS() {
gulp.src(`${srcDir}/ts/**/*.ts`) return gulp.src(`${srcDir}/ts/**/*.ts`)
.pipe(ts("tsconfig.json")) .pipe(ts("tsconfig.json"))
.pipe(terser()) .pipe(terser())
.pipe(gulp.dest(`tmp/js`)); .pipe(gulp.dest(`tmp/js`));
}
function bundleJS() {
return gulp.src('tmp/js/*.js') return gulp.src('tmp/js/*.js')
.pipe(webpack({ mode: 'production', output: { filename: 'app.min.js' } })) .pipe(webpack({ mode: 'production', output: { filename: 'app.min.js' } }))
.pipe(gulp.dest(`${destDir}/js`)); .pipe(gulp.dest(`${destDir}/js`));
@ -46,4 +48,4 @@ function copyImages() {
} }
exports.default = compileSCSS; exports.default = compileSCSS;
exports.default = gulp.parallel(compileSCSS, compileTS, copyCSS, copyJS, copyImages); exports.default = gulp.parallel(compileSCSS, gulp.series(compileTS, bundleJS), copyCSS, copyJS, copyImages);

View File

@ -26,6 +26,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-
src\ts\BlogPost.ts = src\ts\BlogPost.ts src\ts\BlogPost.ts = src\ts\BlogPost.ts
src\ts\Author.ts = src\ts\Author.ts src\ts\Author.ts = src\ts\Author.ts
src\ts\TimeUtility.ts = src\ts\TimeUtility.ts src\ts\TimeUtility.ts = src\ts\TimeUtility.ts
src\ts\UI.ts = src\ts\UI.ts
EndProjectSection EndProjectSection
EndProject EndProject
Global Global

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Humanizer;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Primitives; using Microsoft.Extensions.Primitives;
using OliverBooth.Data.Blog; using OliverBooth.Data.Blog;
using OliverBooth.Services; using OliverBooth.Services;
@ -43,6 +44,7 @@ public sealed class BlogApiController : ControllerBase
title = post.Title, title = post.Title,
published = post.Published.ToUnixTimeSeconds(), published = post.Published.ToUnixTimeSeconds(),
updated = post.Updated?.ToUnixTimeSeconds(), updated = post.Updated?.ToUnixTimeSeconds(),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogService.GetExcerpt(post, out bool trimmed), excerpt = _blogService.GetExcerpt(post, out bool trimmed),
trimmed, trimmed,
url = Url.Page("/Blog/Article", url = Url.Page("/Blog/Article",

View File

@ -1,79 +1,41 @@
@page @page
@using Humanizer
@using OliverBooth.Data.Blog
@using OliverBooth.Services
@model OliverBooth.Pages.Blog.Index @model OliverBooth.Pages.Blog.Index
@inject BlogService BlogService
<div id="all_blog_posts"> <div id="all-blog-posts">
<div id="blog_loading_spinner" class="d-flex justify-content-center"> <div id="blog-loading-spinner" class="d-flex justify-content-center">
<div class="spinner-border text-light" role="status"> <div class="spinner-border text-light" role="status">
<p class="text-center sr-only">Loading...</p> <p class="text-center sr-only">Loading...</p>
</div> </div>
</div> </div>
</div> </div>
@foreach (BlogPost post in ArraySegment<BlogPost>.Empty /*BlogService.AllPosts*/) <script id="blog-post-template" type="text/x-handlebars-template">
{ <div class="card-body">
BlogService.TryGetAuthor(post, out Author? author); <h2>
DateTimeOffset published = post.Published; <a href="{{post.url}}"> {{post.title}}</a>
DateTimeOffset timestamp = post.Updated ?? published; </h2>
bool isUpdated = post.Updated.HasValue;
var year = published.ToString("yyyy");
var month = published.ToString("MM");
var day = published.ToString("dd");
<div class="card blog-card" style="margin-bottom: 50px;"> <p class="text-muted">
<div class="card-body"> <img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
<h2> <span>{{author.name}}<span>
<a asp-page="/blog/article" <span> &bull; </span>
asp-route-year="@year" <span title="{{ post.date }}">{{ post.date_humanized }}</span>
asp-route-month="@month" {{#if post.enable_comments}}
asp-route-day="@day" <span> &bull; </span>
asp-route-slug="@post.Slug"> <a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
@post.Title 0 Comments
</a> </a>
</h2> {{/if}}
</p>
<p class="text-muted"> <p>{{{post.excerpt}}}</p>
<img class="blog-author-icon" src="https://gravatar.com/avatar/@author?.AvatarHash?s=28" alt="@author?.Name">
@author?.Name &bull;
<abbr data-bs-toggle="tooltip" data-bs-title="@timestamp.ToString("f")"> {{#if post.trimmed}}
@(isUpdated ? "Updated" : "Published") @timestamp.Humanize() <p>
</abbr> <a href="{{post.url}}">
Read more...
@if (post.EnableComments) </a>
{
<span> &bull;</span>
<a asp-page="/blog/article"
asp-fragment="disqus_thread"
asp-route-year="@year"
asp-route-month="@month"
asp-route-day="@day"
asp-route-slug="@post.Slug"
data-disqus-identifier="@post.GetDisqusIdentifier()">
0 Comments
</a>
}
</p> </p>
{{/if}}
<p>@Html.Raw(BlogService.GetExcerpt(post, out bool trimmed))</p>
@if (trimmed)
{
<p>
<a asp-page="/blog/article"
asp-route-year="@year"
asp-route-month="@month"
asp-route-day="@day"
asp-route-slug="@post.Slug">
Read more...
</a>
</p>
}
</div>
</div> </div>
</script>
<script id="dsq-count-scr" src="https://oliverbooth-dev.disqus.com/count.js" async></script>
}

View File

@ -70,6 +70,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/js/bootstrap.bundle.min.js" integrity="sha512-ToL6UYWePxjhDQKNioSi4AyJ5KkRxY+F1+Fi7Jgh0Hp5Kk2/s8FD7zusJDdonfe5B00Qw+B8taXxF6CFLnqNCw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.1/js/bootstrap.bundle.min.js" integrity="sha512-ToL6UYWePxjhDQKNioSi4AyJ5KkRxY+F1+Fi7Jgh0Hp5Kk2/s8FD7zusJDdonfe5B00Qw+B8taXxF6CFLnqNCw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.js" integrity="sha512-aoZChv+8imY/U1O7KIHXvO87EOzCuKO0GhFtpD6G2Cyjo/xPeTgdf3/bchB10iB+AojMTDkMHDPLKNxPJVqDcw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/KaTeX/0.16.8/katex.min.js" integrity="sha512-aoZChv+8imY/U1O7KIHXvO87EOzCuKO0GhFtpD6G2Cyjo/xPeTgdf3/bchB10iB+AojMTDkMHDPLKNxPJVqDcw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js" integrity="sha512-uKQ39gEGiyUJl4AI6L+ekBdGKpGw4xJ55+xyJG7YFlJokPNYegn9KwQ3P8A7aFQAUtUsAQHep+d/lrGqrbPIDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js" integrity="sha512-uKQ39gEGiyUJl4AI6L+ekBdGKpGw4xJ55+xyJG7YFlJokPNYegn9KwQ3P8A7aFQAUtUsAQHep+d/lrGqrbPIDQ==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.7.8/handlebars.min.js" integrity="sha512-E1dSFxg+wsfJ4HKjutk/WaCzK7S2wv1POn1RRPGh8ZK+ag9l244Vqxji3r6wgz9YBf6+vhQEYJZpSjqWFPg9gg==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="~/js/prism.min.js" asp-append-version="true"></script> <script src="~/js/prism.min.js" asp-append-version="true"></script>
<script src="~/js/app.min.js" asp-append-version="true"></script> <script src="~/js/app.min.js" asp-append-version="true"></script>

View File

@ -1,4 +1,5 @@
using Markdig; using Markdig;
using Markdig.Extensions.MediaLinks;
using NLog.Extensions.Logging; using NLog.Extensions.Logging;
using OliverBooth.Data; using OliverBooth.Data;
using OliverBooth.Markdown; using OliverBooth.Markdown;

View File

@ -61,7 +61,25 @@ public sealed class BlogService
int moreIndex = span.IndexOf("<!--more-->", StringComparison.Ordinal); int moreIndex = span.IndexOf("<!--more-->", StringComparison.Ordinal);
trimmed = moreIndex != -1 || span.Length > 256; trimmed = moreIndex != -1 || span.Length > 256;
string result = moreIndex != -1 ? span[..moreIndex].Trim().ToString() : post.Body.Truncate(256); string result = moreIndex != -1 ? span[..moreIndex].Trim().ToString() : post.Body.Truncate(256);
return RenderContent(result); return RenderContent(result).Trim();
}
/// <summary>
/// Attempts to find the author by ID.
/// </summary>
/// <param name="id">The ID of the author.</param>
/// <param name="author">
/// When this method returns, contains the <see cref="Author" /> associated with the specified ID, if the author
/// is found; otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the author is found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
public bool TryGetAuthor(int id, [NotNullWhen(true)] out Author? author)
{
using BlogContext context = _dbContextFactory.CreateDbContext();
author = context.Authors.FirstOrDefault(a => a.Id == id);
return author is not null;
} }
/// <summary> /// <summary>
@ -71,6 +89,7 @@ public sealed class BlogService
/// <param name="author"> /// <param name="author">
/// When this method returns, contains the <see cref="Author" /> associated with the specified blog post, if the /// When this method returns, contains the <see cref="Author" /> associated with the specified blog post, if the
/// author is found; otherwise, <see langword="null" />. /// author is found; otherwise, <see langword="null" />.
/// </param>
/// <returns><see langword="true" /> if the author is found; otherwise, <see langword="false" />.</returns> /// <returns><see langword="true" /> if the author is found; otherwise, <see langword="false" />.</returns>
/// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception> /// <exception cref="ArgumentNullException"><paramref name="post" /> is <see langword="null" />.</exception>
public bool TryGetAuthor(BlogPost post, [NotNullWhen(true)] out Author? author) public bool TryGetAuthor(BlogPost post, [NotNullWhen(true)] out Author? author)

View File

@ -212,7 +212,7 @@ div.alert *:last-child {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
#blog_loading_spinner { #blog-loading-spinner {
margin: 20px; margin: 20px;
&.removed { &.removed {

View File

@ -1,6 +1,6 @@
class Author { class Author {
private _name: string; private readonly _name: string;
private _avatarHash: string; private readonly _avatarHash: string;
constructor(json: any) { constructor(json: any) {
this._name = json.name; this._name = json.name;

View File

@ -9,6 +9,7 @@
private readonly _url: string; private readonly _url: string;
private readonly _trimmed: boolean; private readonly _trimmed: boolean;
private readonly _identifier: string; private readonly _identifier: string;
private readonly _humanizedTimestamp: string;
constructor(json: any) { constructor(json: any) {
this._id = json.id; this._id = json.id;
@ -21,6 +22,7 @@
this._url = json.url; this._url = json.url;
this._trimmed = json.trimmed; this._trimmed = json.trimmed;
this._identifier = json.identifier; this._identifier = json.identifier;
this._humanizedTimestamp = json.humanizedTimestamp;
} }
get id(): number { get id(): number {
@ -62,6 +64,10 @@
get identifier(): string { get identifier(): string {
return this._identifier; return this._identifier;
} }
get humanizedTimestamp(): string {
return this._humanizedTimestamp;
}
} }
export default BlogPost; export default BlogPost;

11
src/ts/UI.ts Normal file
View File

@ -0,0 +1,11 @@
class UI {
public static get blogPostContainer(): HTMLDivElement {
return document.querySelector("#all-blog-posts");
}
public static get blogPostTemplate(): HTMLDivElement {
return document.querySelector("#blog-post-template");
}
}
export default UI;

View File

@ -1,12 +1,15 @@
import API from "./API"; import API from "./API";
import TimeUtility from "./TimeUtility"; import TimeUtility from "./TimeUtility";
import UI from "./UI";
declare const bootstrap: any; declare const bootstrap: any;
declare const katex: any; declare const katex: any;
declare const Handlebars: any;
(() => { (() => {
const blogPostContainer = document.querySelector("#all_blog_posts"); const blogPostContainer = UI.blogPostContainer;
if (blogPostContainer) { if (blogPostContainer) {
const template = Handlebars.compile(UI.blogPostTemplate.innerHTML);
API.getBlogPostCount().then(async (count) => { API.getBlogPostCount().then(async (count) => {
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const posts = await API.getBlogPosts(i, 5); const posts = await API.getBlogPosts(i, 5);
@ -20,63 +23,23 @@ declare const katex: any;
card.classList.add("animate__fadeIn"); card.classList.add("animate__fadeIn");
card.style.marginBottom = "50px"; card.style.marginBottom = "50px";
const cardBody = document.createElement("div"); const body = template({
cardBody.classList.add("card-body"); post: {
card.appendChild(cardBody); title: post.title,
excerpt: post.excerpt,
const postTitle = document.createElement("h2"); url: post.url,
postTitle.classList.add("card-title"); date: TimeUtility.formatRelativeTimestamp(post.published),
cardBody.appendChild(postTitle); date_humanized: `${post.updated ? "Updated" : "Published"} ${post.humanizedTimestamp}`,
enable_comments: post.commentsEnabled,
const titleLink = document.createElement("a"); disqus_identifier: post.identifier,
titleLink.href = post.url; trimmed: post.trimmed,
titleLink.innerText = post.title; },
postTitle.appendChild(titleLink); author: {
name: author.name,
const metadata = document.createElement("p"); avatar: `https://gravatar.com/avatar/${author.avatarHash}?s=28`,
metadata.classList.add("text-muted"); }
cardBody.appendChild(metadata); });
card.innerHTML = body.trim();
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} &bull; `;
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 = " &bull; ";
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 &hellip;";
cardBody.appendChild(readMoreLink);
}
blogPostContainer.appendChild(card); blogPostContainer.appendChild(card);
} }
@ -89,7 +52,7 @@ declare const katex: any;
disqusCounter.src = "https://oliverbooth-dev.disqus.com/count.js"; disqusCounter.src = "https://oliverbooth-dev.disqus.com/count.js";
disqusCounter.async = true; disqusCounter.async = true;
const spinner = document.querySelector("#blog_loading_spinner"); const spinner = document.querySelector("#blog-loading-spinner");
if (spinner) { if (spinner) {
spinner.classList.add("removed"); spinner.classList.add("removed");
setTimeout(() => spinner.remove(), 1100); setTimeout(() => spinner.remove(), 1100);