Compare commits

..

2 Commits

15 changed files with 1183 additions and 13 deletions

View File

@ -4,6 +4,7 @@ const cleanCSS = require('gulp-clean-css');
const rename = require('gulp-rename'); const rename = require('gulp-rename');
const ts = require('gulp-typescript'); const ts = require('gulp-typescript');
const terser = require('gulp-terser'); const terser = require('gulp-terser');
const webpack = require('webpack-stream');
const srcDir = 'src'; const srcDir = 'src';
const destDir = 'OliverBooth/wwwroot'; const destDir = 'OliverBooth/wwwroot';
@ -17,10 +18,13 @@ function compileSCSS() {
} }
function compileTS() { function compileTS() {
return gulp.src(`${srcDir}/ts/**/*.ts`) gulp.src(`${srcDir}/ts/**/*.ts`)
.pipe(ts()) .pipe(ts("tsconfig.json"))
.pipe(terser()) .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`)); .pipe(gulp.dest(`${destDir}/js`));
} }

View File

@ -22,6 +22,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "ts", "ts", "{BB9F76AC-292A-
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
src\ts\app.ts = src\ts\app.ts src\ts\app.ts = src\ts\app.ts
src\ts\prism.js = src\ts\prism.js 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 EndProjectSection
EndProject EndProject
Global Global

View 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,
});
}
}

View File

@ -5,7 +5,15 @@
@model OliverBooth.Pages.Blog.Index @model OliverBooth.Pages.Blog.Index
@inject BlogService BlogService @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); BlogService.TryGetAuthor(post, out Author? author);
DateTimeOffset published = post.Published; DateTimeOffset published = post.Published;

View File

@ -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/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/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/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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@100;400;700&display=swap" rel="stylesheet">

View File

@ -1,5 +0,0 @@
{
"compilerOptions": {
"target": "es2020"
}
}

813
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -26,6 +26,7 @@
"gulp-terser": "^2.1.0", "gulp-terser": "^2.1.0",
"gulp-typescript": "^6.0.0-alpha.1", "gulp-typescript": "^6.0.0-alpha.1",
"node-sass": "^9.0.0", "node-sass": "^9.0.0",
"terser": "^5.19.2" "terser": "^5.19.2",
"webpack-stream": "^7.0.0"
} }
} }

View File

@ -211,3 +211,28 @@ code[class*="language-"] {
div.alert *:last-child { div.alert *:last-child {
margin-bottom: 0 !important; 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
View 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
View 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
View 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
View 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;

View File

@ -1,7 +1,102 @@
import API from "./API";
import TimeUtility from "./TimeUtility";
declare const bootstrap: any; declare const bootstrap: any;
declare const katex: 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} &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);
}
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 formatRelativeTime = function (timestamp) {
const now = new Date(); const now = new Date();
// @ts-ignore // @ts-ignore

6
tsconfig.json Normal file
View File

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