Author SHA1 Message Date
Josh-Heaps 52d468a46e Update blog css 2026-03-07 12:26:58 -07:00
Josh-Heaps ea60ee5a4b fix css 2026-03-06 17:15:51 -07:00
Josh-Heaps 99a9474ef7 Add cache invalidation 2026-03-06 17:05:48 -07:00
Josh-HeapsandClaude Opus 4.6 8043eee4cc Replace file-based blog with API-backed service
BlogService now fetches from Media.JoshHeaps.Net API via HttpClient
with in-memory caching (5-min TTL) and stale cache fallback.
Removed Markdig/YamlDotNet dependencies. All page models now async.

Co-Authored-By: Claude Opus 4.6 <[email protected]>
2026-03-06 15:33:51 -07:00
Josh Heaps 5fd3a5d7c1 Merge pull request #12 from JoshHeaps/AddProject
Add cloud storage to projects section of front page
2025-10-22 11:58:33 -06:00
Josh-Heaps 9969c6e3cf Add cloud storage to projects section of front page 2025-10-22 11:54:48 -06:00
Josh Heaps 0788015cb0 Merge pull request #11 from JoshHeaps/feature/generalUpdate
Feature/general update
2025-10-17 13:06:02 -06:00
jheaps 34de3c1abc Update href for chess github 2025-10-17 12:57:17 -06:00
jheaps 311d62a719 Update images 2025-10-17 12:48:10 -06:00
jheaps 4217bf3703 Fix headless issue 2025-10-17 12:29:49 -06:00
jheaps fd68ada82e Get fancy wit it 2025-10-17 12:14:37 -06:00
jheaps 7c77cd0e52 Add square numbers and bug fixes 2025-10-17 12:10:08 -06:00
jheaps 93bdb3518a Bug fixes 2025-10-17 11:41:50 -06:00
Josh Heaps 69e1e8450d Merge pull request #10 from JoshHeaps/tech-debt/chess-cleanup
Add demo button for media.joshheaps.net
2025-10-13 10:18:17 -06:00
Josh Heaps e74de40d2e Merge pull request #9 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-03 11:27:23 -06:00
Josh Heaps 9b86bfafe0 Merge pull request #8 from JoshHeaps/tech-debt/chess-cleanup
Tech debt/chess cleanup
2025-10-02 10:19:41 -06:00
38 changed files with 1350 additions and 276 deletions
+3 -1
View File
@@ -3,7 +3,9 @@
"allow": [ "allow": [
"Bash(dotnet test:*)", "Bash(dotnet test:*)",
"Bash(dotnet build)", "Bash(dotnet build)",
"Bash(dir:*)" "Bash(dir:*)",
"Bash(dotnet add:*)",
"Bash(dotnet build:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []
+13
View File
@@ -11,6 +11,19 @@ public class ApiTests : PageTest
private IAPIRequestContext? _apiContext; private IAPIRequestContext? _apiContext;
private TestConfiguration Config => TestConfiguration.Instance; private TestConfiguration Config => TestConfiguration.Instance;
[OneTimeSetUp]
public async Task OneTimeSetUp()
{
if (!Config.Playwright.Headless)
{
Environment.SetEnvironmentVariable("HEADED", "1");
}
if (Config.Playwright.SlowMotion > 0)
{
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
}
}
[SetUp] [SetUp]
public async Task Setup() public async Task Setup()
{ {
@@ -0,0 +1,24 @@
---
title: My First Post
date: 2026-03-06
summary: Welcome to my blog! Here I'll share thoughts on software development, projects I'm working on, and things I find interesting.
tags: [dev, personal]
---
# Welcome to My Blog
I've been meaning to start writing about the things I build and learn, and I'm finally getting around to it.
## What to Expect
I plan to write about:
- **Projects** I'm working on, like this website and the other things on my portfolio
- **Software development** tips and patterns I find useful
- **Problem solving** approaches that have helped me grow as a developer
## Why a Blog?
Building things is great, but explaining *how* and *why* you built them is just as valuable. Writing forces you to organize your thoughts, and hopefully someone else finds it useful along the way.
Stay tuned for more posts!
@@ -0,0 +1,20 @@
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
namespace JoshHeaps.Net.Controllers;
[ApiController]
[Route("api/[controller]")]
public class BlogController(IBlogService blogService, IConfiguration configuration) : ControllerBase
{
[HttpPost("invalidate")]
public IActionResult InvalidateCache([FromHeader(Name = "X-Invalidate-Key")] string? key)
{
var expectedKey = configuration["BlogApi:InvalidateKey"];
if (string.IsNullOrEmpty(expectedKey) || key != expectedKey)
return Unauthorized();
blogService.ClearCache();
return Ok();
}
}
+3 -3
View File
@@ -22,7 +22,7 @@ public class ChessController(
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = []; private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1); private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromSeconds(5); private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1); private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
/// <summary> /// <summary>
@@ -165,7 +165,7 @@ public class ChessController(
var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId; var expectedPlayerId = isWhiteMove ? gameState.WhitePlayerId : gameState.BlackPlayerId;
if (moveDto.PlayerId != expectedPlayerId) if (moveDto.PlayerId != expectedPlayerId)
return Forbid("You are not the current player."); return StatusCode(403, "You are not the current player.");
// Make sure player owns the piece // Make sure player owns the piece
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId); var piece = gameState.Pieces.FirstOrDefault(p => p.Id == moveDto.PieceId);
@@ -174,7 +174,7 @@ public class ChessController(
return NotFound("Chess piece Id does not exist"); return NotFound("Chess piece Id does not exist");
if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black)) if ((isWhiteMove && piece.Color != PieceColor.White) || (!isWhiteMove && piece?.Color != PieceColor.Black))
return Forbid("You cannot move this piece."); return StatusCode(403, "You cannot move this piece.");
var result = chessService.MakeMove(gameState, moveDto); var result = chessService.MakeMove(gameState, moveDto);
+1
View File
@@ -4,6 +4,7 @@
<TargetFramework>net8.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>53ed685c-bdff-4306-8cc2-9fbe55c85713</UserSecretsId>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+12
View File
@@ -0,0 +1,12 @@
namespace JoshHeaps.Net.Models;
public class BlogPost
{
public long Id { get; set; }
public string Slug { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public DateTime PublishedDate { get; set; }
public string Summary { get; set; } = string.Empty;
public List<string> Tags { get; set; } = [];
public string HtmlContent { get; set; } = string.Empty;
}
+49
View File
@@ -0,0 +1,49 @@
@page
@model JoshHeaps.Net.Pages.BlogModel
@{
ViewData["Title"] = "Blog - JoshHeaps.Net";
Layout = "_Layout";
}
<div class="blog-index">
<h1 class="blog-title">Blog</h1>
@if (Model.ActiveTag is not null)
{
<div class="blog-filter">
<span>Filtered by: <strong>@Model.ActiveTag</strong></span>
<a href="/blog">Clear filter</a>
</div>
}
@if (Model.Posts.Count == 0)
{
<p class="blog-empty">No posts yet. Check back soon!</p>
}
<div class="blog-list">
@foreach (var post in Model.Posts)
{
<article class="blog-card">
<a href="/blog/@post.Slug" class="blog-card-link">
<h2 class="blog-card-title">@post.Title</h2>
</a>
<time class="blog-card-date" datetime="@post.PublishedDate.ToString("yyyy-MM-dd")">
@post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<p class="blog-card-summary">@post.Summary</p>
<div class="blog-card-tags">
@foreach (var tag in post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</article>
}
</div>
</div>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/index.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
+26
View File
@@ -0,0 +1,26 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages;
public class BlogModel : PageModel
{
private readonly IBlogService _blogService;
public List<BlogPost> Posts { get; set; } = [];
public string? ActiveTag { get; set; }
public BlogModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task OnGetAsync(string? tag)
{
ActiveTag = tag;
Posts = tag is not null
? await _blogService.GetPostsByTagAsync(tag)
: await _blogService.GetAllPostsAsync();
}
}
+38
View File
@@ -0,0 +1,38 @@
@page "/blog/{slug}"
@model JoshHeaps.Net.Pages.Blog.PostModel
@{
ViewData["Title"] = Model.Post!.Title + " - JoshHeaps.Net";
Layout = "_Layout";
}
<article class="blog-post">
<a href="/blog" class="blog-back">&larr; Back to blog</a>
<header class="blog-post-header">
<h1 class="blog-post-title">@Model.Post.Title</h1>
<time class="blog-post-date" datetime="@Model.Post.PublishedDate.ToString("yyyy-MM-dd")">
@Model.Post.PublishedDate.ToString("MMMM d, yyyy")
</time>
<div class="blog-post-tags">
@foreach (var tag in Model.Post.Tags)
{
<a href="/blog?tag=@tag" class="blog-tag">@tag</a>
}
</div>
</header>
<div class="post-content">
@Html.Raw(Model.Post.HtmlContent)
</div>
</article>
@section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/post.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/blog/prism-tomorrow.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
}
@section Scripts {
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/components/prism-core.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs@1/plugins/autoloader/prism-autoloader.min.js"></script>
}
+28
View File
@@ -0,0 +1,28 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages.Blog;
public class PostModel : PageModel
{
private readonly IBlogService _blogService;
public BlogPost? Post { get; set; }
public PostModel(IBlogService blogService)
{
_blogService = blogService;
}
public async Task<IActionResult> OnGetAsync(string slug)
{
Post = await _blogService.GetPostBySlugAsync(slug);
if (Post is null)
return NotFound();
return Page();
}
}
+76 -62
View File
@@ -1,101 +1,115 @@
@page @page
@model JoshHeaps.Net.Pages.IndexModel @model JoshHeaps.Net.Pages.IndexModel
@{ @{
ViewData["Title"] = "JoshHeaps.Net"; ViewData["Title"] = "JoshHeaps.Net";
Layout = "_Layout"; Layout = "_Layout";
} }
<header id="Header"> <nav id="Header">
<a class="headerOption" onclick="showClickedContents('projects')">./ Projects</a> <span class="site-name">josh heaps</span>
<a class="headerOption" onclick="showClickedContents('demos')">./ Demos</a> <div class="nav-links">
<a class="headerOption" onclick="showClickedContents('contact')">./ Contact me</a> <a class="nav-link" onclick="showClickedContents('projects')">Projects</a>
</header> <a class="nav-link" onclick="showClickedContents('demos')">Demos</a>
<a class="nav-link" onclick="showClickedContents('blog')">Blog</a>
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
</div>
</nav>
<div id="WelcomeMessage"></div> <section id="Hero">
<div id="WelcomeMessage"></div>
</section>
<div id="ProjectsBox"> <section id="ProjectsBox">
<div id="ChessProject" class="projectContainer"> <h2 class="section-title">Projects</h2>
<div class="displayBox diagonal-section-left"> <div class="projects-grid">
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess"> <article class="project-card">
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/> <a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
</a> </a>
<div class="project-info">
<h3 class="project-title">Chess</h3>
<p class="project-description">As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. It was made using .NET razor pages, a .NET web api backend, and a good amount of javascript on the frontend. Feel free to check it out in the demos below :)</p>
<a class="project-link" href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank">View on GitHub &rarr;</a>
</div> </div>
<div id="ChessText" class="projectTextRight"> </article>
<h2 id="ChessTitle" class="projectHeaderRight projectHeader">Chess</h2>
<p id="ChessDescription" class="projectDescriptionRight projectDescription">
As someone who loves chess, I wanted to create a chess game that I could play with my friends and family. This project is a work in progress, as currently it can only be played locally. It was built in C# on .NET 8.0, using winforms, because I like a challenge. I plan to add online multiplayer functionality in the future.
</p>
</div>
</div>
<div id="CompilerProject" class="projectContainer"> <article class="project-card">
<div id="CompilerText" class="projectTextLeft"> <a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2> <img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
<p id="CompilerDescription" class="projectDescriptionLeft projectDescription">
I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.
</p>
</div>
<div class="displayBox diagonal-section-right">
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
</a> </a>
<div class="project-info">
<h3 class="project-title">Compiler</h3>
<p class="project-description">I love solving problems, and figuring out how things work. What better way to do both than writing a compiler myself? This project is a compiler that I wrote in C# on .NET 8.0. It takes in a simple language that I created, outputs the corresponding common intermediate language (CIL), and executes it. I plan to add more features in the future.</p>
<a class="project-link" href="https://github.com/JoshHeaps/CILCompiler" target="_blank">View on GitHub &rarr;</a>
</div> </div>
</div> </article>
</div>
<div id="DemosBox" class="displayBox"> <article class="project-card">
<h2 id="DemoHeader">Demos</h2> <a href="https://media.joshheaps.net" target="_blank" class="project-image-link">
<img class="projectImage" src="/images/CloudStorageDemo.png" alt="Cloud Storage Screenshot" />
</a>
<div class="project-info">
<h3 class="project-title">Image Cloud Storage</h3>
<p class="project-description">My wife and I LOVE to take pictures and videos of our kids, but we do not like paying for extra cloud storage. I made this for my family so we can upload pictures to my server, share them with each other, and now we don't need to worry about how much space we're using.</p>
<a class="project-link" href="https://media.joshheaps.net" target="_blank">View Live Demo &rarr;</a>
</div>
</article>
</div>
</section>
<section id="DemosBox">
<h2 class="section-title">Demos</h2>
<div id="buttonWrapper"> <div id="buttonWrapper">
<button id="ChessDemo" class="demoButton" onclick="window.location.href='/chess'"> <button class="demoButton" onclick="window.location.href='/chess'">Play Chess</button>
Play Chess <button class="demoButton" onclick="window.location.href='/particles'">Particle Simulator</button>
</button> <button class="demoButton" onclick="window.location.href='/memorylane'">Memory Lane</button>
<button id="ParticleDemo" class="demoButton" onclick="window.location.href='/particles'"> <button class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">Cloud Image Storage</button>
Particle Simulator <button id="MoreFiller" class="demoButton">More coming soon...</button>
</button>
<button id="MemoryLane" class="demoButton" onclick="window.location.href='/memorylane'">
Memory Lane
</button>
<button id="CloadStorageDemo" class="demoButton" onclick="window.location.href='https://media.joshheaps.net'">
Cloud Image Storage
</button>
<button id="MoreFiller" class="demoButton">
More coming soon...
</button>
</div> </div>
</div> </section>
<section id="BlogBox">
<h2 class="section-title">Latest Posts</h2>
<div id="blogPostsList">
@foreach (var post in Model.LatestPosts)
{
<div class="blog-teaser-item">
<a class="blog-teaser-title" href="/blog/@post.Slug">@post.Title</a>
<span class="blog-teaser-date">@post.PublishedDate.ToString("MMMM d, yyyy")</span>
<p class="blog-teaser-summary">@post.Summary</p>
</div>
}
</div>
<a class="blog-view-all" href="/blog">View all posts &rarr;</a>
</section>
<footer id="Contacts"> <footer id="Contacts">
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<svg viewBox="0 0 16 16" class="social-icon"> <svg viewBox="0 0 16 16" class="social-icon">
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path> <path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"></path>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg> </svg>
</a>
<a class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<svg viewBox="0 0 24 24" class="social-icon"> <svg viewBox="0 0 24 24" class="social-icon">
<a id="LinkedinLink" class="social-link" href="https://www.linkedin.com/in/josh-heaps/" target="_blank">
<path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path> <path d="M20.5 2h-17A1.5 1.5 0 002 3.5v17A1.5 1.5 0 003.5 22h17a1.5 1.5 0 001.5-1.5v-17A1.5 1.5 0 0020.5 2zM8 19H5v-9h3zM6.5 8.25A1.75 1.75 0 118.3 6.5a1.78 1.78 0 01-1.8 1.75zM19 19h-3v-4.74c0-1.42-.6-1.93-1.38-1.93A1.74 1.74 0 0013 14.19a.66.66 0 000 .14V19h-3v-9h2.9v1.3a3.11 3.11 0 012.7-1.4c1.55 0 3.36.86 3.36 3.66z"></path>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg> </svg>
</a>
<a class="social-link" href="mailto:[email protected]">
<svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none"> <svg viewBox="0 -4 31 31" class="social-icon" preserveAspectRatio="none">
<a id="GmailLink" class="social-link" href="mailto:[email protected]">
<path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/> <path d="m0 3.23863636v2.72727273l3.12784091 3.02727273 3.69034091 2.08636368.68181818-4.59034095-.68181818-4.27329546-1.90909091-1.43181818c-2.02329546-1.51704546-4.90909091-.07329545-4.90909091 2.45454545"/>
<path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/> <path d="m23.1818182 2.21590909-.6818182 4.32954546.6818182 4.53409095 3.3494318-1.65852277 3.46875-3.45511364v-2.72727273c0-2.5278409-2.8857955-3.97159091-4.9090909-2.45454545z"/>
<path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/> <path d="m2.04545455 22.6704545h4.77272727v-11.590909l-6.81818182-5.11363641v14.65909091c0 1.1301136.91534091 2.0454545 2.04545455 2.0454545"/>
<path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/> <path d="m23.1818182 22.6704545h4.7727273c1.1301136 0 2.0454545-.9153409 2.0454545-2.0454545v-14.65909091l-6.8181818 5.11363641z"/>
<path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/> <path d="m15 8.35227273-8.18181818-6.13636364v8.86363641l8.18181818 6.1363636 8.1818182-6.1363636v-8.86363641z"/>
<rect width="100%" height="100%" fill="transparent" />
</a>
</svg> </svg>
</a>
</footer> </footer>
@section Styles { @section Styles {
<link href="/css/variables.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/site.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/project.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageLeftProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/blog.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/imageRightProject.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
<link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" /> <link href="/css/home/footer.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
} }
+8 -3
View File
@@ -1,12 +1,17 @@
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.AspNetCore.Mvc.RazorPages;
namespace JoshHeaps.Net.Pages namespace JoshHeaps.Net.Pages
{ {
public class IndexModel() : PageModel public class IndexModel(IBlogService blogService) : PageModel
{
public void OnGet()
{ {
public List<BlogPost> LatestPosts { get; set; } = [];
public async Task OnGetAsync()
{
var allPosts = await blogService.GetAllPostsAsync();
LatestPosts = allPosts.Take(3).ToList();
} }
} }
} }
+1 -1
View File
@@ -1,6 +1,6 @@
@{ @{
Layout = null; Layout = null;
ViewData["cssVersion"] = "1.0.2"; // <--- change this once to bust cache ViewData["cssVersion"] = "1.0.5"; // <--- change this once to bust cache
} }
<!DOCTYPE html> <!DOCTYPE html>
+7
View File
@@ -12,6 +12,13 @@ builder.Services.AddControllers();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
builder.Services.AddHttpClient("BlogApi", client =>
{
var baseUrl = configuration["BlogApi:BaseUrl"] ?? "https://media.joshheaps.net";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(10);
});
builder.Services.AddSingleton<IBlogService, BlogService>();
builder.Services.AddSingleton<IChessService, ChessService>(); builder.Services.AddSingleton<IChessService, ChessService>();
builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>(); builder.Services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
@@ -8,31 +8,23 @@ public class AutoIpUpdateService(
ILogger<AutoIpUpdateService> log) ILogger<AutoIpUpdateService> log)
: BackgroundService : BackgroundService
{ {
private static readonly TimeSpan CheckInterval = TimeSpan.FromMinutes(5);
private static readonly HttpClient httpClient = new();
public static bool IsEnabled { get; private set; } = false; public static bool IsEnabled { get; private set; } = false;
private static readonly TimeSpan _checkInterval = TimeSpan.FromMinutes(1);
private static readonly HttpClient _httpClient = new();
protected override async Task ExecuteAsync(CancellationToken stop) protected override async Task ExecuteAsync(CancellationToken stop)
{ {
IsEnabled = true; IsEnabled = true;
var timer = new PeriodicTimer(CheckInterval); var timer = new PeriodicTimer(_checkInterval);
AAAARecord dnsRecord = await GetDnsRecordAsync();
string lastKnownIp = dnsRecord.Content;
while (await timer.WaitForNextTickAsync(stop)) while (await timer.WaitForNextTickAsync(stop))
{ {
try try
{ {
string currentIp = await GetPublicIpAsync() ?? ""; await UpdateIpAddressIfChanged();
if (lastKnownIp != currentIp)
{
await UpdateDnsIpAsync(config, dnsRecord, currentIp);
lastKnownIp = currentIp;
} }
} catch (OperationCanceledException) { break; }
catch (OperationCanceledException) { /* shutting down */ }
catch (Exception ex) catch (Exception ex)
{ {
log.LogError(ex, "Error while attempting ip update"); log.LogError(ex, "Error while attempting ip update");
@@ -40,11 +32,33 @@ public class AutoIpUpdateService(
} }
} }
private async Task UpdateIpAddressIfChanged()
{
var dnsRecords = await GetDnsRecordAsync();
string dnsRecordIp = dnsRecords[0].Content;
if (!dnsRecords.Records.All(x => x.Content == dnsRecords[0].Content))
dnsRecordIp = string.Empty;
string publicIp = await GetPublicIpAsync() ?? string.Empty;
if (string.IsNullOrWhiteSpace(publicIp) || dnsRecordIp == publicIp)
return;
foreach (var dnsRecord in dnsRecords.Records)
{
if (dnsRecord.Content == publicIp)
continue;
await UpdateDnsIpAsync(config, dnsRecord, publicIp);
}
}
private static async Task<string> GetPublicIpAsync() private static async Task<string> GetPublicIpAsync()
{ {
try try
{ {
return await httpClient.GetStringAsync(@"https://api.ipify.org/"); return await _httpClient.GetStringAsync(@"https://api.ipify.org/");
} }
catch catch
{ {
@@ -55,7 +69,7 @@ public class AutoIpUpdateService(
} }
} }
private async Task<AAAARecord> GetDnsRecordAsync() private async Task<RecordList> GetDnsRecordAsync()
{ {
try try
{ {
@@ -64,8 +78,8 @@ public class AutoIpUpdateService(
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records"); var result = await cfClient.GetAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records");
Console.WriteLine(await result.Content.ReadAsStringAsync()); Console.WriteLine(await result.Content.ReadAsStringAsync());
var records = System.Text.Json.JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync()); var records = JsonSerializer.Deserialize<RecordList>(await result.Content.ReadAsStringAsync());
return records!.Result[0]; return records!;
} }
catch catch
{ {
@@ -76,22 +90,23 @@ public class AutoIpUpdateService(
} }
} }
private static async Task UpdateDnsIpAsync(IConfiguration config, AAAARecord record, string ip) private static async Task UpdateDnsIpAsync(IConfiguration config, DnsRecord record, string ip)
{ {
HttpClient cfClient = new(); HttpClient cfClient = new();
cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Email", config["cfEmail"]);
cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]); cfClient.DefaultRequestHeaders.Add("X-Auth-Key", config["cfKey"]);
object content = new
object updateRequestBody = new
{ {
comment = "Update as needed", name = record.Name,
ttl = record.Ttl,
type = record.Type,
comment = record.Comment,
content = ip, content = ip,
name = "@", proxied = record.Proxied,
proxied = true,
ttl = 3600,
type = "AAAA"
}; };
var result = await cfClient.PutAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records{record.Id}", content); var result = await cfClient.PatchAsJsonAsync(@$"https://api.cloudflare.com/client/v4/zones/{config["zoneId"]}/dns_records/{record.Id}", updateRequestBody);
if (!result.IsSuccessStatusCode) if (!result.IsSuccessStatusCode)
{ {
@@ -102,11 +117,23 @@ public class AutoIpUpdateService(
} }
} }
record AAAARecord( record DnsRecord(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("ttl")] int Ttl,
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("comment")] string Comment, [property: JsonPropertyName("comment")] string Comment,
[property: JsonPropertyName("content")] string Content, [property: JsonPropertyName("content")] string Content,
[property: JsonPropertyName("name")] string Name, [property: JsonPropertyName("proxied")] bool Proxied,
[property: JsonPropertyName("id")] string Id); [property: JsonPropertyName("id")] string Id);
record RecordList([property: JsonPropertyName("result")] List<AAAARecord> Result); record RecordList([property: JsonPropertyName("result")] List<DnsRecord> Records)
{
public DnsRecord this[int index]
{
get
{
return Records[index];
}
}
}
} }
@@ -0,0 +1,94 @@
using System.Collections.Concurrent;
using System.Text.Json;
using JoshHeaps.Net.Models;
using JoshHeaps.Net.Services.Interfaces;
namespace JoshHeaps.Net.Services.Implementations;
public class BlogService : IBlogService
{
private readonly HttpClient _httpClient;
private readonly ILogger<BlogService> _logger;
private readonly TimeSpan _cacheTtl = TimeSpan.FromMinutes(5);
private readonly ConcurrentDictionary<string, CacheEntry> _cache = new();
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true
};
public BlogService(IHttpClientFactory httpClientFactory, ILogger<BlogService> logger)
{
_httpClient = httpClientFactory.CreateClient("BlogApi");
_logger = logger;
}
public async Task<List<BlogPost>> GetAllPostsAsync()
{
return await GetCachedAsync("all_posts",
() => FetchAsync<List<BlogPost>>("api/blog/posts")) ?? [];
}
public async Task<BlogPost?> GetPostBySlugAsync(string slug)
{
return await GetCachedAsync($"post_{slug}",
() => FetchAsync<BlogPost>($"api/blog/posts/{Uri.EscapeDataString(slug)}"));
}
public async Task<List<BlogPost>> GetPostsByTagAsync(string tag)
{
return await GetCachedAsync($"tag_{tag}",
() => FetchAsync<List<BlogPost>>($"api/blog/posts/tags/{Uri.EscapeDataString(tag)}")) ?? [];
}
private async Task<T?> FetchAsync<T>(string path) where T : class
{
try
{
var response = await _httpClient.GetAsync(path);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Blog API returned {StatusCode} for {Path}", response.StatusCode, path);
return null;
}
var json = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(json, JsonOptions);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to fetch from blog API: {Path}", path);
return null;
}
}
private async Task<T?> GetCachedAsync<T>(string key, Func<Task<T?>> factory) where T : class
{
if (_cache.TryGetValue(key, out var entry) && entry.ExpiresAt > DateTime.UtcNow)
return (T?)entry.Value;
var result = await factory();
if (result is not null)
{
_cache[key] = new CacheEntry(result, DateTime.UtcNow.Add(_cacheTtl));
}
else if (entry is not null)
{
// API unreachable — serve stale cache
_logger.LogWarning("Serving stale cache for key {Key}", key);
return (T?)entry.Value;
}
return result;
}
public void ClearCache()
{
_cache.Clear();
_logger.LogInformation("Blog cache cleared");
}
private record CacheEntry(object Value, DateTime ExpiresAt);
}
@@ -110,7 +110,6 @@ public class ChessService : IChessService
var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId); var piece = gameState.Pieces.FirstOrDefault(p => p.Id == pieceId);
if (piece == null) return []; if (piece == null) return [];
if (piece.Color != gameState.CurrentPlayer) return [];
var candidateMoves = GenerateCandidateMoves(gameState, piece); var candidateMoves = GenerateCandidateMoves(gameState, piece);
var legalMoves = new List<Position>(); var legalMoves = new List<Position>();
@@ -529,8 +528,15 @@ public class ChessService : IChessService
var ep = gs.EnPassantTarget.Value; var ep = gs.EnPassantTarget.Value;
if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1) if (ep.Row == forward1 && Math.Abs(ep.Col - startCol) == 1)
{
// Verify there's an enemy pawn to capture
int enemyPawnRow = piece.Color == PieceColor.White ? ep.Row + 1 : ep.Row - 1;
var enemyPawn = gs.Board[enemyPawnRow, ep.Col];
if (enemyPawn != null && enemyPawn.Type == PieceType.Pawn && enemyPawn.Color != piece.Color)
moves.Add(ep); moves.Add(ep);
} }
}
return moves; return moves;
} }
@@ -542,11 +548,7 @@ public class ChessService : IChessService
=> GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]); => GenerateSlidingMoves(gs, piece, [(1, 1), (1, -1), (-1, 1), (-1, -1)]);
private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece) private static List<Position> GenerateQueenMoves(GameState gs, ChessPiece piece)
=> GenerateSlidingMoves(gs, piece, => [..GenerateRookMoves(gs, piece), ..GenerateBishopMoves(gs, piece)];
[
(1, 0), (-1, 0), (0, 1), (0, -1),
(1, 1), (1, -1), (-1, 1), (-1, -1)
]);
private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions) private static List<Position> GenerateSlidingMoves(GameState gs, ChessPiece piece, (int dr, int dc)[] directions)
{ {
@@ -0,0 +1,11 @@
using JoshHeaps.Net.Models;
namespace JoshHeaps.Net.Services.Interfaces;
public interface IBlogService
{
Task<List<BlogPost>> GetAllPostsAsync();
Task<BlogPost?> GetPostBySlugAsync(string slug);
Task<List<BlogPost>> GetPostsByTagAsync(string tag);
void ClearCache();
}
+5 -1
View File
@@ -5,5 +5,9 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*" "AllowedHosts": "*",
"BlogApi": {
"BaseUrl": "https://media.joshheaps.net",
"InvalidateKey": "CHANGE_ME"
}
} }
+126
View File
@@ -0,0 +1,126 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-index {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 2.5rem;
}
.blog-filter {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.blog-filter strong {
color: var(--color-heading-secondary);
}
.blog-filter a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-filter a:hover {
color: var(--color-accent-hover);
}
.blog-empty {
color: var(--color-text-subtle);
font-style: italic;
font-size: 1rem;
}
.blog-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-card {
padding: 1.5rem 1.75rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-card:hover {
background: var(--color-surface-hover);
}
.blog-card-link {
text-decoration: none;
}
.blog-card-title {
color: var(--color-heading-secondary);
font-size: 1.25rem;
font-weight: 600;
margin: 0 0 0.3rem;
letter-spacing: -0.01em;
transition: color 0.2s;
}
.blog-card-link:hover .blog-card-title {
color: var(--color-accent);
}
.blog-card-date {
color: var(--color-text-subtle);
font-size: 0.75rem;
display: block;
margin-bottom: 0.5rem;
}
.blog-card-summary {
color: var(--color-text-muted);
margin: 0;
line-height: 1.6;
font-size: 0.875rem;
}
.blog-card-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
+184
View File
@@ -0,0 +1,184 @@
*, *::before, *::after {
box-sizing: border-box;
}
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
.blog-post {
max-width: 800px;
margin: 0 auto;
padding: 4rem clamp(1.5rem, 5vw, 3rem);
}
.blog-back {
color: var(--color-text-subtle);
text-decoration: none;
font-size: 0.85rem;
transition: color 0.2s;
}
.blog-back:hover {
color: var(--color-heading-secondary);
}
.blog-post-header {
margin: 2.5rem 0 2.5rem;
}
.blog-post-title {
color: var(--color-heading);
font-size: clamp(1.75rem, 3vw, 2.25rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 0.5rem;
line-height: 1.25;
}
.blog-post-date {
color: var(--color-text-subtle);
font-size: 0.8rem;
display: block;
}
.blog-post-tags {
display: flex;
gap: 0.4rem;
flex-wrap: wrap;
margin-top: 0.75rem;
}
.blog-tag {
background: var(--color-surface-raised);
color: var(--color-text-muted);
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.7rem;
text-decoration: none;
transition: background 0.2s, color 0.2s;
}
.blog-tag:hover {
background: var(--color-surface-raised-hover);
color: var(--color-heading-secondary);
}
/* ── Post Content ── */
.post-content {
color: var(--color-text);
line-height: 1.8;
font-size: 1rem;
}
.post-content h1,
.post-content h2,
.post-content h3,
.post-content h4 {
color: var(--color-heading-secondary);
margin-top: 2rem;
margin-bottom: 0.75rem;
letter-spacing: -0.01em;
}
.post-content h1 { font-size: 1.6rem; }
.post-content h2 { font-size: 1.35rem; }
.post-content h3 { font-size: 1.15rem; }
.post-content p {
margin: 0 0 1.25rem;
}
.post-content a {
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.post-content a:hover {
color: var(--color-accent-hover);
}
.post-content ul,
.post-content ol {
margin: 0 0 1.25rem 1.5rem;
}
.post-content li {
margin-bottom: 0.4rem;
}
.post-content code {
background: var(--color-surface-raised);
color: var(--color-heading-secondary);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.85em;
font-family: var(--font-mono);
}
.post-content pre {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 1.25rem 1.5rem;
overflow-x: auto;
margin: 0 0 1.25rem;
scrollbar-width: thin;
scrollbar-color: var(--color-border) transparent;
}
.post-content pre::-webkit-scrollbar {
height: 6px;
}
.post-content pre::-webkit-scrollbar-track {
background: transparent;
}
.post-content pre::-webkit-scrollbar-thumb {
background: var(--color-border);
border-radius: 3px;
}
.post-content pre[class*="language-"] {
background: var(--color-surface);
}
.post-content pre code {
background: none;
color: var(--prism-text);
padding: 0;
font-size: 0.85rem;
}
.post-content blockquote {
border-left: 2px solid var(--color-accent-border);
margin: 0 0 1.25rem;
padding: 0.75rem 1.25rem;
color: var(--color-text-muted);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
}
.post-content img {
max-width: 100%;
border-radius: 8px;
margin: 1.25rem 0;
}
.post-content strong {
color: var(--color-heading-secondary);
}
.post-content hr {
border: none;
border-top: 1px solid var(--color-border);
margin: 2rem 0;
}
@@ -0,0 +1,154 @@
/* Prism.js Tomorrow Night theme (customized) */
/* Original: https://github.com/PrismJS/prism/blob/master/themes/prism-tomorrow.css */
/* ============================================
Theme colors — edit these to restyle
============================================ */
:root {
--prism-background: #0a0a0a;
--prism-text: #909090;
--prism-comment: #555;
--prism-punctuation: #707070;
--prism-tag: #09ff00;
--prism-function: #fff;
--prism-boolean: #1ab815;
--prism-number: #1ab815;
--prism-property: #c8c8c8;
--prism-class: #c8c8c8;
--prism-keyword: #09ff00;
--prism-string: #43c760;
--prism-variable: #43c760;
--prism-operator: #999;
--prism-inserted: #09ff00;
}
code[class*="language-"],
pre[class*="language-"] {
color: var(--prism-text);
background: none;
font-family: var(--font-mono);
font-size: 1em;
text-align: left;
white-space: pre;
word-spacing: normal;
word-break: normal;
word-wrap: normal;
line-height: 1.5;
tab-size: 4;
hyphens: none;
}
pre[class*="language-"] {
padding: 1em;
margin: 0.5em 0;
overflow: auto;
}
:not(pre) > code[class*="language-"],
pre[class*="language-"] {
background: var(--prism-background);
}
:not(pre) > code[class*="language-"] {
padding: 0.1em;
border-radius: 0.3em;
white-space: normal;
}
/* Comments */
.token.comment,
.token.block-comment,
.token.prolog,
.token.doctype,
.token.cdata {
color: var(--prism-comment);
}
/* Punctuation */
.token.punctuation {
color: var(--prism-punctuation);
}
/* Tags, attributes, deleted */
.token.tag,
.token.attr-name,
.token.namespace,
.token.deleted {
color: var(--prism-tag);
}
/* Function names */
.token.function-name,
.token.function {
color: var(--prism-function);
}
/* Booleans */
.token.boolean {
color: var(--prism-boolean);
}
/* Numbers */
.token.number {
color: var(--prism-number);
}
/* Properties */
.token.property {
color: var(--prism-property);
}
/* Classes, constants, symbols */
.token.class-name,
.token.constant,
.token.symbol {
color: var(--prism-class);
}
/* Keywords, selectors, builtins */
.token.selector,
.token.important,
.token.atrule,
.token.keyword,
.token.builtin {
color: var(--prism-keyword);
}
/* Strings, chars, attr values, regex */
.token.string,
.token.char,
.token.attr-value,
.token.regex {
color: var(--prism-string);
}
/* Variables */
.token.variable {
color: var(--prism-variable);
}
/* Operators, entities, URLs */
.token.operator,
.token.entity,
.token.url {
color: var(--prism-operator);
}
/* Inserted */
.token.inserted {
color: var(--prism-inserted);
}
/* Formatting */
.token.bold,
.token.important {
font-weight: 700;
}
.token.italic {
font-style: italic;
}
.token.entity {
cursor: help;
}
+32 -1
View File
@@ -1,5 +1,10 @@
#chessBoard { #boardContainer {
position: relative;
width: fit-content;
margin: 2vw auto; margin: 2vw auto;
}
#chessBoard {
width: 60vw; width: 60vw;
height: 60vw; height: 60vw;
display: grid; display: grid;
@@ -159,3 +164,29 @@
height: 50px; height: 50px;
pointer-events: none; /* ensures img doesn't steal the click */ pointer-events: none; /* ensures img doesn't steal the click */
} }
.chessSquare .coordinate-label {
position: absolute;
font-size: 1.2vw;
font-weight: bold;
pointer-events: none;
user-select: none;
}
.chessSquare.light .coordinate-label {
color: #656770;
}
.chessSquare.dark .coordinate-label {
color: #ccc;
}
.chessSquare .row-label {
top: 2px;
left: 4px;
}
.chessSquare .col-label {
bottom: 2px;
right: 4px;
}
+65
View File
@@ -0,0 +1,65 @@
/* ── Blog Section ── */
#BlogBox {
max-width: 1100px;
margin: clamp(4rem, 8vw, 8rem) auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
#blogPostsList {
display: flex;
flex-direction: column;
gap: 1rem;
}
.blog-teaser-item {
display: flex;
flex-direction: column;
gap: 0.3rem;
padding: 1.25rem 1.5rem;
border-left: 2px solid var(--color-accent-border);
background: var(--color-surface);
border-radius: 0 8px 8px 0;
transition: background 0.2s;
}
.blog-teaser-item:hover {
background: var(--color-surface-hover);
}
.blog-teaser-title {
font-size: clamp(1rem, 1.5vw, 1.15rem);
font-weight: 600;
color: var(--color-heading-secondary);
text-decoration: none;
transition: color 0.2s;
}
.blog-teaser-title:hover {
color: var(--color-accent);
}
.blog-teaser-date {
font-size: 0.75rem;
color: var(--color-text-subtle);
}
.blog-teaser-summary {
font-size: 0.85rem;
color: var(--color-text-muted);
margin: 0.25rem 0 0;
line-height: 1.6;
}
.blog-view-all {
display: inline-block;
margin-top: 1.5rem;
font-size: 0.85rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.blog-view-all:hover {
color: var(--color-accent-hover);
}
+18 -10
View File
@@ -1,17 +1,25 @@
#Contacts { /* ── Footer / Contacts ── */
margin-top: 20vw;
#Contacts {
display: flex;
justify-content: center;
gap: 1.5rem;
padding: clamp(4rem, 8vw, 6rem) 0 clamp(3rem, 5vw, 4rem);
}
.social-link {
display: flex;
align-items: center;
text-decoration: none;
} }
.social-icon { .social-icon {
height: auto; height: auto;
width: 3.5vw; width: clamp(1.25rem, 2.5vw, 1.75rem);
margin-bottom: 5vw; fill: var(--color-text-subtle);
margin-left: 3vw; transition: fill 0.2s;
fill: rgb(212, 212, 212);
transition: fill 0.5s;
} }
.social-icon:hover { .social-link:hover .social-icon {
fill: white; fill: var(--color-heading-secondary);
transition: fill 0.5s;
} }
@@ -1,36 +0,0 @@
.diagonal-section-left {
position: absolute;
clip-path: polygon(0 0, 85vw 0, 60vw 100%, 0 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-left:has(.projectImage:hover) {
clip-path: polygon(0 0, 87vw 0, 58vw 100%, 0 100%);
transition: clip-path 0.5s ease;
}
.projectImageLeft {
width: 33vw;
height: auto;
margin: 3vw;
}
.projectTextRight {
position: relative;
z-index: 1;
margin-left: 50vw;
}
.projectHeaderRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
}
.projectDescriptionRight {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
}
@@ -1,38 +0,0 @@
.diagonal-section-right {
position: absolute;
clip-path: polygon(40vw 0, 100% 0, 100% 100%, 15vw 100%);
background-color: rgb(104, 255, 0, 0.39);
transition: clip-path 0.5s ease;
}
.diagonal-section-right:has(.projectImage:hover) {
clip-path: polygon(42vw 0, 100% 0, 100% 100%, 13vw 100%);
transition: clip-path 0.5s ease;
}
.projectImageRight {
width: 33vw;
height: auto;
margin: 3vw;
float: right;
}
.projectTextLeft {
position: relative;
z-index: 1;
}
.projectHeaderLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 4vw;
margin-left: 10vw;
}
.projectDescriptionLeft {
margin: 0;
color: rgb(212, 212, 212);
font-size: 2vw;
width: 40vw;
margin-left: 10vw;
}
+77 -16
View File
@@ -1,29 +1,90 @@
#ProjectsBox { /* ── Projects Section ── */
#ProjectsBox {
max-width: 1100px;
margin: 0 auto;
padding: 0 clamp(1.5rem, 5vw, 4rem);
}
.projects-grid {
display: grid; display: grid;
grid-auto-rows: 1fr; grid-template-columns: repeat(auto-fill, minmax(min(320px, 100%), 1fr));
gap: 20vw; gap: 1.5rem;
} }
.projectContainer { /* ── Card ── */
position: relative;
display: flex; .project-card {
align-items: center; border: 1px solid var(--color-border);
border-radius: 12px;
overflow: hidden;
background: var(--color-surface);
transition: border-color 0.25s, transform 0.25s;
} }
.displayBox { .project-card:hover {
width: 100vw; border-color: var(--color-border-hover);
height: auto; transform: translateY(-2px);
} }
a.projectContainer { /* ── Image ── */
text-decoration: none;
.project-image-link {
display: block;
overflow: hidden;
} }
.projectImage { .projectImage {
transition: width 0.5s ease; width: 100%;
height: auto;
display: block;
transition: transform 0.4s ease;
} }
.projectImage:hover { .project-card:hover .projectImage {
width: 40vw; transform: scale(1.03);
transition: width 0.5s ease; }
/* ── Info ── */
.project-info {
padding: 1.25rem 1.5rem 1.5rem;
}
.project-title {
margin: 0 0 0.5rem;
font-size: 1.1rem;
font-weight: 600;
color: var(--color-heading);
letter-spacing: -0.01em;
}
.project-description {
margin: 0 0 1rem;
font-size: 0.85rem;
line-height: 1.65;
color: var(--color-text-muted);
}
.project-link {
font-size: 0.8rem;
color: var(--color-accent);
text-decoration: none;
transition: color 0.2s;
}
.project-link:hover {
color: var(--color-accent-hover);
}
/* ── Mobile ── */
@media (max-width: 600px) {
.projects-grid {
grid-template-columns: 1fr;
}
.project-info {
padding: 1rem 1.25rem 1.25rem;
}
} }
+108 -41
View File
@@ -1,67 +1,134 @@
body { *, *::before, *::after {
margin: 0px; box-sizing: border-box;
background-color: #2b2c30;
color: #09ff00;
overflow-x: hidden;
cursor: default;
} }
body {
margin: 0;
background-color: var(--color-bg);
color: var(--color-text);
overflow-x: hidden;
font-family: var(--font-body);
line-height: 1.6;
}
/* ── Navigation ── */
#Header { #Header {
margin-top: 2vw; display: flex;
justify-content: space-between;
align-items: center;
max-width: 1100px;
margin: 0 auto;
padding: 2rem clamp(1.5rem, 5vw, 4rem);
} }
.headerOption { .site-name {
font: bold 2vw arial; font-size: 1.05rem;
display: inline; font-weight: 700;
margin-left: 10vw; color: var(--color-heading);
letter-spacing: -0.01em;
}
.nav-links {
display: flex;
gap: clamp(1.25rem, 3vw, 2.5rem);
}
.nav-link {
font-size: 0.875rem;
color: var(--color-text-subtle);
cursor: pointer; cursor: pointer;
transition: color 0.5s ease, font 0.5s ease; transition: color 0.2s;
text-decoration: none;
} }
.headerOption:hover { .nav-link:hover {
font: bold 2.3vw arial; color: var(--color-heading-secondary);
transition: color 0.5s ease, font 0.5s ease; }
/* ── Hero / Welcome Message ── */
#Hero {
max-width: 1100px;
margin: 0 auto;
padding: clamp(4rem, 10vw, 8rem) clamp(1.5rem, 5vw, 4rem) clamp(4rem, 8vw, 6rem);
} }
#WelcomeMessage { #WelcomeMessage {
font: 3vw arial; font-size: clamp(1.25rem, 2.2vw, 1.75rem);
width: auto; max-width: 720px;
height: 40vw; min-height: clamp(12rem, 28vw, 20rem);
margin-top: 15vw; line-height: 1.7;
text-align: left; color: var(--color-accent);
padding-left: 10vw; font-family: var(--font-mono);
padding-right: 10vw;
} }
/* ── Section Titles ── */
.section-title {
font-size: clamp(1.3rem, 2.2vw, 1.75rem);
font-weight: 700;
color: var(--color-heading);
margin: 0 0 clamp(1.5rem, 3vw, 2.5rem);
letter-spacing: -0.02em;
}
/* ── Demos ── */
#DemosBox { #DemosBox {
margin: 10vw; max-width: 1100px;
display: flex; margin: clamp(4rem, 8vw, 8rem) auto;
flex-direction: column; padding: 0 clamp(1.5rem, 5vw, 4rem);
justify-content: end;
width: 80%;
} }
#DemoHeader { #buttonWrapper {
margin: 0; display: grid;
color: rgb(212, 212, 212); grid-template-columns: repeat(auto-fill, minmax(min(200px, 100%), 1fr));
font-size: 4vw; gap: 0.75rem;
} }
.demoButton { .demoButton {
margin-top: 5vw; padding: 0.875rem 1.25rem;
margin-right: 2vw;
padding-top: 0.5vw;
padding-bottom: 0.5vw;
text-align: center; text-align: center;
border: none; border: 1px solid var(--color-border);
border-radius: 5vw; border-radius: 8px;
width: 20vw; font-size: 0.875rem;
height: 8vw; font-family: inherit;
font-size: 2vw; background: var(--color-surface);
background-color: rgb(104, 255, 0, 0.39); color: var(--color-text);
cursor: pointer; cursor: pointer;
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.demoButton:hover {
background: var(--color-surface-hover);
border-color: var(--color-border-hover);
color: var(--color-heading-secondary);
} }
#MoreFiller { #MoreFiller {
cursor: default; cursor: default;
opacity: 0.3;
}
#MoreFiller:hover {
background: var(--color-surface);
border-color: var(--color-border);
color: var(--color-text);
}
/* ── Mobile ── */
@media (max-width: 600px) {
#Header {
padding: 1.25rem 1.25rem;
}
.site-name {
font-size: 0.95rem;
}
.nav-link {
font-size: 0.8rem;
}
} }
+28
View File
@@ -0,0 +1,28 @@
:root {
/* Backgrounds */
--color-bg: #0a0a0a;
--color-surface: rgba(255, 255, 255, 0.02);
--color-surface-hover: rgba(255, 255, 255, 0.04);
--color-surface-raised: rgba(255, 255, 255, 0.05);
--color-surface-raised-hover: rgba(255, 255, 255, 0.08);
/* Text */
--color-heading: #fff;
--color-heading-secondary: #e0e0e0;
--color-text: #b0b0b0;
--color-text-muted: #aaa;
--color-text-subtle: #777;
/* Accent */
--color-accent: #09ff00;
--color-accent-hover: #5fff5f;
/* Borders */
--color-border: rgba(255, 255, 255, 0.07);
--color-border-hover: rgba(255, 255, 255, 0.15);
--color-accent-border: rgba(9, 255, 0, 0.4);
/* Typography */
--font-body: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
--font-mono: 'SF Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 132 KiB

@@ -27,9 +27,14 @@ const ChessAPI = {
body: JSON.stringify(moveDto) body: JSON.stringify(moveDto)
}); });
if (!response.ok) {
let message = await response.text();
throw new Error(message || "Invalid move or not your turn.");
}
const result = await response.json(); const result = await response.json();
if (!response.ok || !result.success) { if (!result.success) {
throw new Error(result.message || "Invalid move or not your turn."); throw new Error(result.message || "Invalid move or not your turn.");
} }
@@ -1,6 +1,7 @@
const ChessBoard = { const ChessBoard = {
renderPieces(pieces) { renderPieces(pieces) {
this.clearAllSquares(); this.clearAllSquares();
this.renderCoordinateLabels();
this.placePieces(pieces); this.placePieces(pieces);
this.setupSquareEventHandlers(); this.setupSquareEventHandlers();
this.highlightPreviousMove(); this.highlightPreviousMove();
@@ -106,6 +107,39 @@ const ChessBoard = {
ChessAPI.handleMove(targetRow, targetCol); ChessAPI.handleMove(targetRow, targetCol);
}; };
} }
},
renderCoordinateLabels() {
const files = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'];
const ranks = ['8', '7', '6', '5', '4', '3', '2', '1'];
// If player is black, reverse the coordinates
if (!GameState.currentPlayerIsWhite) {
files.reverse();
ranks.reverse();
}
for (let i = 0; i < 64; i++) {
const square = document.getElementById(`square-${i}`);
const row = Math.floor(i / 8);
const col = i % 8;
// Add rank label (1-8) on the leftmost column
if (col === 0) {
const rankLabel = document.createElement('span');
rankLabel.className = 'coordinate-label row-label';
rankLabel.textContent = ranks[row];
square.appendChild(rankLabel);
}
// Add file label (a-h) on the bottom row
if (row === 7) {
const fileLabel = document.createElement('span');
fileLabel.className = 'coordinate-label col-label';
fileLabel.textContent = files[col];
square.appendChild(fileLabel);
}
}
} }
}; };
+1 -1
View File
@@ -3,5 +3,5 @@
element.style.display = "none"; // Trigger reflow element.style.display = "none"; // Trigger reflow
element.offsetHeight; // Force reflow element.offsetHeight; // Force reflow
element.style.display = ""; // Restore the original display element.style.display = ""; // Restore the original display
typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid````````````````Just````uhhhh`````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?"); typeText("#WelcomeMessage", "Hey, you found me...!?``````````````````````No that's stupid...```````````````````Just...```````uhhhh...````````Hello! My name is Josh. I'm a software engineer, and I try to have a lot of fun with what I do. While I have your attention, why don't I tell you more about myself?");
} }
+17 -9
View File
@@ -12,18 +12,19 @@
let intervalId; let intervalId;
let index = 0; let index = 0;
const punctuation = ['.', '!', '?']; const punctuation = ['.', '!', '?'];
const punctuationDict = {
'.': 150,
'!': 200,
'?': 200,
',': 100,
'`': 25,
};
let currentText = ""; let currentText = "";
function changeIntervalTime(element, text) { function changeIntervalTime(element, text) {
if (punctuation.includes(text.charAt(index))) {
clearInterval(intervalId); clearInterval(intervalId);
simulateTyping(element, text, 200); simulateTyping(element, text, punctuationDict[text.charAt(index)] ?? 50);
}
else {
clearInterval(intervalId);
simulateTyping(element, text);
}
} }
function simulateTyping(element, text, speed = 50) { function simulateTyping(element, text, speed = 50) {
@@ -44,9 +45,9 @@ function simulateTyping(element, text, speed = 50) {
function showClickedContents(option) { function showClickedContents(option) {
if (option === 'projects') { if (option === 'projects') {
document.querySelector("#ChessProject > div.diagonal-section-left").scrollIntoView({ document.querySelector("#ProjectsBox").scrollIntoView({
behavior: "smooth", behavior: "smooth",
block: "center", block: "start",
inline: "nearest" inline: "nearest"
}); });
} }
@@ -67,4 +68,11 @@ function showClickedContents(option) {
inline: "nearest" inline: "nearest"
}); });
} }
else if (option === 'blog') {
document.querySelector("#BlogBox").scrollIntoView({
behavior: "smooth",
block: "start",
inline: "nearest"
});
}
} }