Compare commits

...

15 Commits

9 changed files with 214 additions and 121 deletions

View File

@ -7,6 +7,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
README.md = README.md
package.json = package.json
Gulpfile.js = Gulpfile.js
tsconfig.json = tsconfig.json
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{8A323E64-E41E-4780-99FD-17BF58961FB5}"

View File

@ -43,6 +43,7 @@ public sealed class BlogApiController : ControllerBase
author = post.AuthorId,
title = post.Title,
published = post.Published.ToUnixTimeSeconds(),
formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
updated = post.Updated?.ToUnixTimeSeconds(),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
excerpt = _blogService.GetExcerpt(post, out bool trimmed),

View File

@ -29,14 +29,14 @@
<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="@published.ToString("f")">
<abbr data-bs-toggle="tooltip" data-bs-title="@published.ToString("dddd, d MMMM yyyy HH:mm")">
Published @published.Humanize()
</abbr>
@if (post.Updated is { } updated)
{
<span>&bull;</span>
<abbr data-bs-toggle="tooltip" data-bs-title="@updated.ToString("f")">
<abbr data-bs-toggle="tooltip" data-bs-title="@updated.ToString("dddd, d MMMM yyyy HH:mm")">
Updated @updated.Humanize()
</abbr>
}

View File

@ -19,11 +19,11 @@
<img class="blog-author-icon" src="{{author.avatar}}" alt="{{author.name}}">
<span>{{author.name}}<span>
<span> &bull; </span>
<span title="{{ post.date }}">{{ post.date_humanized }}</span>
<abbr title="{{ post.formattedDate }}">{{ post.date_humanized }}</abbr>
{{#if post.enable_comments}}
<span> &bull; </span>
<a href="{{post.url}}#disqus_thread" data-disqus-identifier="{{post.disqus_identifier}}">
0 Comments
Loading comment count &hellip;
</a>
{{/if}}
</p>
@ -38,4 +38,4 @@
</p>
{{/if}}
</div>
</script>
</script>

View File

@ -71,7 +71,7 @@
<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/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" data-manual></script>
<script src="~/js/app.min.js" asp-append-version="true"></script>
@await RenderSectionAsync("Scripts", required: false)

View File

@ -10,6 +10,7 @@
private readonly _trimmed: boolean;
private readonly _identifier: string;
private readonly _humanizedTimestamp: string;
private readonly _formattedDate: string;
constructor(json: any) {
this._id = json.id;
@ -23,6 +24,7 @@
this._trimmed = json.trimmed;
this._identifier = json.identifier;
this._humanizedTimestamp = json.humanizedTimestamp;
this._formattedDate = json.formattedDate;
}
get id(): number {
@ -68,6 +70,10 @@
get humanizedTimestamp(): string {
return this._humanizedTimestamp;
}
get formattedDate(): string {
return this._formattedDate;
}
}
export default BlogPost;

View File

@ -1,4 +1,12 @@
class UI {
import BlogPost from "./BlogPost";
import Author from "./Author";
import TimeUtility from "./TimeUtility";
declare const bootstrap: any;
declare const katex: any;
declare const Prism: any;
class UI {
public static get blogPostContainer(): HTMLDivElement {
return document.querySelector("#all-blog-posts");
}
@ -6,6 +14,190 @@
public static get blogPostTemplate(): HTMLDivElement {
return document.querySelector("#blog-post-template");
}
/**
* Creates a <script> element that loads the Disqus comment counter.
*/
public static createDisqusCounterScript(): HTMLScriptElement {
const script = document.createElement("script");
script.id = "dsq-count-scr";
script.src = "https://oliverbooth-dev.disqus.com/count.js";
script.async = true;
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: post.url,
date: TimeUtility.formatRelativeTimestamp(post.published),
formattedDate: post.formattedDate,
date_humanized: `${post.updated ? "Updated" : "Published"} ${post.humanizedTimestamp}`,
enable_comments: post.commentsEnabled,
disqus_identifier: post.identifier,
trimmed: post.trimmed,
},
author: {
name: author.name,
avatar: `https://gravatar.com/avatar/${author.avatarHash}?s=28`,
}
});
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.
*/
public static updateUI(element?: Element) {
element = element || document.body;
UI.addLineNumbers(element);
UI.addHighlighting(element);
UI.addBootstrapTooltips(element);
UI.renderTeX(element);
UI.renderTimestamps(element);
UI.unescapeMarkTags(element);
}
/**
* Adds Bootstrap tooltips to all elements with a title attribute.
* @param element The element to search for elements with a title attribute in.
*/
public static addBootstrapTooltips(element?: Element) {
element = element || document.body;
element.querySelectorAll("[title]").forEach((el) => {
el.setAttribute("data-bs-toggle", "tooltip");
el.setAttribute("data-bs-placement", "bottom");
el.setAttribute("data-bs-html", "true");
el.setAttribute("data-bs-title", el.getAttribute("title"));
new bootstrap.Tooltip(el);
});
}
/**
* Adds line numbers to all <pre> <code> blocks that have more than one line.
* @param element The element to search for <pre> <code> blocks in.
*/
public static addLineNumbers(element?: Element) {
element = element || document.body;
element.querySelectorAll("pre code").forEach((block) => {
let content = block.textContent;
if (content.trim().split("\n").length > 1) {
block.parentElement.classList.add("line-numbers");
}
});
}
/**
* Adds syntax highlighting to all <pre> <code> blocks in the element.
* @param element The element to search for <pre> <code> blocks in.
*/
public static addHighlighting(element?: Element) {
element = element || document.body;
element.querySelectorAll("pre code").forEach((block) => {
Prism.highlightAllUnder(block.parentElement);
});
}
/**
* Renders all TeX in the document.
* @param element The element to search for TeX in.
*/
public static renderTeX(element?: Element) {
element = element || document.body;
const tex = element.getElementsByClassName("math");
Array.from(tex).forEach(function (el: Element) {
let content = el.textContent.trim();
if (content.startsWith("\\[")) content = content.slice(2);
if (content.endsWith("\\]")) content = content.slice(0, -2);
katex.render(content, el);
});
}
/**
* Renders Discord-style <t:timestamp:format> tags.
* @param element The element to search for timestamps in.
*/
public static renderTimestamps(element?: Element) {
element = element || document.body;
const timestamps = element.querySelectorAll("span[data-timestamp][data-format]");
timestamps.forEach((timestamp) => {
const seconds = parseInt(timestamp.getAttribute("data-timestamp"));
const format = timestamp.getAttribute("data-format");
const date = new Date(seconds * 1000);
const shortTimeString = date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
const shortDateString = date.toLocaleDateString([], {day: "2-digit", month: "2-digit", year: "numeric"});
const longTimeString = date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"});
const longDateString = date.toLocaleDateString([], {day: "numeric", month: "long", year: "numeric"});
const weekday = date.toLocaleString([], {weekday: "long"});
timestamp.setAttribute("title", `${weekday}, ${longDateString} ${shortTimeString}`);
switch (format) {
case "t":
timestamp.textContent = shortTimeString;
break;
case "T":
timestamp.textContent = longTimeString;
break;
case "d":
timestamp.textContent = shortDateString;
break;
case "D":
timestamp.textContent = longDateString;
break;
case "f":
timestamp.textContent = `${longDateString} at ${shortTimeString}`
break;
case "F":
timestamp.textContent = `${weekday}, ${longDateString} at ${shortTimeString}`
break;
case "R":
setInterval(() => {
timestamp.textContent = TimeUtility.formatRelativeTimestamp(date);
}, 1000);
break;
}
});
}
/**
* Unescapes all <mark> tags in <pre> <code> blocks.
* @param element The element to search for <pre> <code> blocks in.
*/
public static unescapeMarkTags(element?: Element) {
element = element || document.body;
element.querySelectorAll("pre code").forEach((block) => {
let content = block.innerHTML;
content = content.replaceAll("&lt;mark&gt;", "<mark>");
content = content.replaceAll("&lt;/mark&gt;", "</mark>");
block.innerHTML = content;
});
}
}
export default UI;

View File

@ -1,9 +1,6 @@
import API from "./API";
import TimeUtility from "./TimeUtility";
import API from "./API";
import UI from "./UI";
declare const bootstrap: any;
declare const katex: any;
declare const Handlebars: any;
(() => {
@ -15,42 +12,15 @@ declare const Handlebars: any;
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 body = template({
post: {
title: post.title,
excerpt: post.excerpt,
url: post.url,
date: TimeUtility.formatRelativeTimestamp(post.published),
date_humanized: `${post.updated ? "Updated" : "Published"} ${post.humanizedTimestamp}`,
enable_comments: post.commentsEnabled,
disqus_identifier: post.identifier,
trimmed: post.trimmed,
},
author: {
name: author.name,
avatar: `https://gravatar.com/avatar/${author.avatarHash}?s=28`,
}
});
card.innerHTML = body.trim();
const card = UI.createBlogPostCard(template, post, author);
blogPostContainer.appendChild(card);
UI.updateUI(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;
document.body.appendChild(UI.createDisqusCounterScript());
const spinner = document.querySelector("#blog-loading-spinner");
if (spinner) {
@ -60,82 +30,5 @@ declare const Handlebars: any;
});
}
document.querySelectorAll("pre code").forEach((block) => {
let content = block.textContent;
if (content.trim().split("\n").length > 1) {
block.parentElement.classList.add("line-numbers");
}
content = block.innerHTML;
// @ts-ignore
content = content.replaceAll("&lt;mark&gt;", "<mark>");
// @ts-ignore
content = content.replaceAll("&lt;/mark&gt;", "</mark>");
block.innerHTML = content;
});
const tex = document.getElementsByClassName("math");
Array.prototype.forEach.call(tex, function (el) {
let content = el.textContent.trim();
if (content.startsWith("\\[")) content = content.slice(2);
if (content.endsWith("\\]")) content = content.slice(0, -2);
katex.render(content, el);
});
const timestamps = document.querySelectorAll("span[data-timestamp][data-format]");
timestamps.forEach((timestamp) => {
const seconds = parseInt(timestamp.getAttribute("data-timestamp"));
const format = timestamp.getAttribute("data-format");
const date = new Date(seconds * 1000);
const shortTimeString = date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit"});
const shortDateString = date.toLocaleDateString([], {day: "2-digit", month: "2-digit", year: "numeric"});
const longTimeString = date.toLocaleTimeString([], {hour: "2-digit", minute: "2-digit", second: "2-digit"});
const longDateString = date.toLocaleDateString([], {day: "numeric", month: "long", year: "numeric"});
const weekday = date.toLocaleString([], {weekday: "long"});
timestamp.setAttribute("title", `${weekday}, ${longDateString} ${shortTimeString}`);
switch (format) {
case "t":
timestamp.textContent = shortTimeString;
break;
case "T":
timestamp.textContent = longTimeString;
break;
case "d":
timestamp.textContent = shortDateString;
break;
case "D":
timestamp.textContent = longDateString;
break;
case "f":
timestamp.textContent = `${longDateString} at ${shortTimeString}`
break;
case "F":
timestamp.textContent = `${weekday}, ${longDateString} at ${shortTimeString}`
break;
case "R":
setInterval(() => {
timestamp.textContent = TimeUtility.formatRelativeTimestamp(date);
}, 1000);
break;
}
});
document.querySelectorAll("[title]").forEach((el) => {
el.setAttribute("data-bs-toggle", "tooltip");
el.setAttribute("data-bs-placement", "bottom");
el.setAttribute("data-bs-html", "true");
el.setAttribute("data-bs-title", el.getAttribute("title"));
});
const list = document.querySelectorAll('[data-bs-toggle="tooltip"]');
list.forEach((el: Element) => new bootstrap.Tooltip(el));
UI.updateUI();
})();

View File

@ -1,6 +1,6 @@
{
"compilerOptions": {
"lib": ["ES2020", "DOM"],
"target": "ES2020"
"lib": ["ES2022", "DOM"],
"target": "ES2022"
}
}