Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52d468a46e | ||
|
|
ea60ee5a4b | ||
|
|
99a9474ef7 | ||
|
|
8043eee4cc | ||
|
|
5fd3a5d7c1 | ||
|
|
9969c6e3cf | ||
|
|
0788015cb0 | ||
|
|
34de3c1abc | ||
|
|
311d62a719 | ||
|
|
4217bf3703 | ||
|
|
fd68ada82e | ||
|
|
7c77cd0e52 | ||
|
|
93bdb3518a | ||
|
|
69e1e8450d | ||
|
|
09b408a75b | ||
|
|
e74de40d2e | ||
|
|
03dd308449 | ||
|
|
54e4f25be7 | ||
|
|
c2622132bc | ||
|
|
9bd7eb283e | ||
|
|
9b86bfafe0 | ||
|
|
95b4ae63ea | ||
|
|
edab13f8e4 | ||
|
|
ea78302041 | ||
|
|
3da59f9e93 | ||
|
|
4bc69d1989 | ||
|
|
c98a78d642 | ||
|
|
b6990a30b7 | ||
|
|
86888b21d2 | ||
|
|
140fbbfa76 | ||
|
|
a176e29388 | ||
|
|
b425c4d5d6 | ||
|
|
3b91055666 | ||
|
|
a3b384b221 | ||
|
|
eb3d15527a | ||
|
|
636f133288 |
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(dotnet test:*)",
|
||||||
|
"Bash(dotnet build)",
|
||||||
|
"Bash(dir:*)",
|
||||||
|
"Bash(dotnet add:*)",
|
||||||
|
"Bash(dotnet build:*)"
|
||||||
|
],
|
||||||
|
"deny": [],
|
||||||
|
"ask": []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
name: Playwright Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [ master, main ]
|
||||||
|
push:
|
||||||
|
branches: [ master, main ]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
playwright-tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup .NET
|
||||||
|
uses: actions/setup-dotnet@v4
|
||||||
|
with:
|
||||||
|
dotnet-version: 8.0.x
|
||||||
|
|
||||||
|
- name: Restore dependencies
|
||||||
|
run: dotnet restore
|
||||||
|
|
||||||
|
- name: Build solution
|
||||||
|
run: dotnet build --no-restore
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: |
|
||||||
|
cd JoshHeaps.Net.UiTests
|
||||||
|
dotnet build
|
||||||
|
# Install Chromium browser
|
||||||
|
pwsh bin/Debug/net8.0/playwright.ps1 install chromium
|
||||||
|
# Install system dependencies manually for Ubuntu 24.04 compatibility
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libasound2t64 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libdrm2 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libxss1 \
|
||||||
|
libasound2-data \
|
||||||
|
xvfb
|
||||||
|
|
||||||
|
- name: Start server in background
|
||||||
|
run: |
|
||||||
|
cd JoshHeaps.Net
|
||||||
|
dotnet run --urls "https://localhost:7118" &
|
||||||
|
echo $! > server.pid
|
||||||
|
# Wait for server to start - try health endpoint first, then chess page
|
||||||
|
echo "Waiting for server to start..."
|
||||||
|
timeout 60 bash -c 'until curl -k -s https://localhost:7118/health >/dev/null 2>&1 || curl -k -s https://localhost:7118/chess >/dev/null 2>&1; do echo "Waiting..."; sleep 2; done'
|
||||||
|
echo "Server appears to be running"
|
||||||
|
sleep 3
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
run: |
|
||||||
|
cd JoshHeaps.Net.UiTests
|
||||||
|
xvfb-run -a dotnet test --logger "trx;LogFileName=test-results.trx" --logger "console;verbosity=detailed"
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
GITHUB_ACTIONS: true
|
||||||
|
DISPLAY: :99
|
||||||
|
|
||||||
|
- name: Stop server
|
||||||
|
if: always()
|
||||||
|
run: |
|
||||||
|
if [ -f JoshHeaps.Net/server.pid ]; then
|
||||||
|
kill $(cat JoshHeaps.Net/server.pid) || true
|
||||||
|
fi
|
||||||
|
pkill -f "dotnet run" || true
|
||||||
|
|
||||||
|
- name: Upload test results
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-test-results
|
||||||
|
path: |
|
||||||
|
JoshHeaps.Net.UiTests/TestResults/
|
||||||
|
JoshHeaps.Net.UiTests/test-results/
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
- name: Upload Playwright videos
|
||||||
|
if: always()
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: playwright-videos
|
||||||
|
path: JoshHeaps.Net.UiTests/test-results/videos/
|
||||||
|
retention-days: 7
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
using Microsoft.Playwright.NUnit;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.UiTests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ApiTests : PageTest
|
||||||
|
{
|
||||||
|
private IAPIRequestContext? _apiContext;
|
||||||
|
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]
|
||||||
|
public async Task Setup()
|
||||||
|
{
|
||||||
|
_apiContext = await Playwright.APIRequest.NewContextAsync(new()
|
||||||
|
{
|
||||||
|
BaseURL = Config.Test.BaseUrl,
|
||||||
|
IgnoreHTTPSErrors = true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[TearDown]
|
||||||
|
public async Task TearDown()
|
||||||
|
{
|
||||||
|
if (_apiContext != null)
|
||||||
|
{
|
||||||
|
await _apiContext.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task JoinGame_Returns_Valid_Response()
|
||||||
|
{
|
||||||
|
var response = await _apiContext!.GetAsync("/api/chess/JoinGame");
|
||||||
|
|
||||||
|
Assert.That(response.Status, Is.EqualTo(200));
|
||||||
|
|
||||||
|
var jsonResponse = await response.JsonAsync();
|
||||||
|
var gameData = JsonSerializer.Deserialize<JsonElement>(jsonResponse.ToString()!);
|
||||||
|
|
||||||
|
Assert.That(gameData.TryGetProperty("id", out _), Is.True);
|
||||||
|
Assert.That(gameData.TryGetProperty("isWhite", out _), Is.True);
|
||||||
|
Assert.That(gameData.TryGetProperty("gameId", out _), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task CreateCPUGame_Returns_Valid_Response()
|
||||||
|
{
|
||||||
|
var response = await _apiContext!.GetAsync("/api/chess/new/5");
|
||||||
|
|
||||||
|
Assert.That(response.Status, Is.EqualTo(200));
|
||||||
|
|
||||||
|
var jsonResponse = await response.JsonAsync();
|
||||||
|
var gameData = JsonSerializer.Deserialize<JsonElement>(jsonResponse.ToString()!);
|
||||||
|
|
||||||
|
Assert.That(gameData.TryGetProperty("id", out _), Is.True);
|
||||||
|
Assert.That(gameData.TryGetProperty("isWhite", out _), Is.True);
|
||||||
|
Assert.That(gameData.TryGetProperty("gameId", out _), Is.True);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetGameState_Returns_Valid_Game_Data()
|
||||||
|
{
|
||||||
|
// First create a game
|
||||||
|
var createResponse = await _apiContext!.GetAsync("/api/chess/JoinGame");
|
||||||
|
var createData = JsonSerializer.Deserialize<JsonElement>((await createResponse.JsonAsync()).ToString()!);
|
||||||
|
var gameId = createData.GetProperty("gameId").GetString();
|
||||||
|
|
||||||
|
// Then get the game state
|
||||||
|
var response = await _apiContext.GetAsync($"/api/chess/{gameId}");
|
||||||
|
|
||||||
|
Assert.That(response.Status, Is.EqualTo(200));
|
||||||
|
|
||||||
|
var jsonResponse = await response.JsonAsync();
|
||||||
|
var gameState = JsonSerializer.Deserialize<JsonElement>(jsonResponse.ToString()!);
|
||||||
|
|
||||||
|
Assert.That(gameState.TryGetProperty("gameId", out _), Is.True);
|
||||||
|
Assert.That(gameState.TryGetProperty("currentPlayer", out _), Is.True);
|
||||||
|
Assert.That(gameState.TryGetProperty("pieces", out var pieces), Is.True);
|
||||||
|
|
||||||
|
// Should have 32 pieces initially
|
||||||
|
Assert.That(pieces.GetArrayLength(), Is.EqualTo(32));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task GetLegalMoves_Returns_Valid_Moves()
|
||||||
|
{
|
||||||
|
// First create a game
|
||||||
|
var createResponse = await _apiContext!.GetAsync("/api/chess/JoinGame");
|
||||||
|
var createData = JsonSerializer.Deserialize<JsonElement>((await createResponse.JsonAsync()).ToString()!);
|
||||||
|
var gameId = createData.GetProperty("gameId").GetString();
|
||||||
|
|
||||||
|
// Get game state to find a piece
|
||||||
|
var gameStateResponse = await _apiContext.GetAsync($"/api/chess/{gameId}");
|
||||||
|
var gameState = JsonSerializer.Deserialize<JsonElement>((await gameStateResponse.JsonAsync()).ToString()!);
|
||||||
|
var pieces = gameState.GetProperty("pieces");
|
||||||
|
var firstPiece = pieces[0];
|
||||||
|
var pieceId = firstPiece.GetProperty("id").GetString();
|
||||||
|
|
||||||
|
// Get legal moves for the piece
|
||||||
|
var response = await _apiContext.GetAsync($"/api/chess/{gameId}/legalMoves/{pieceId}");
|
||||||
|
|
||||||
|
Assert.That(response.Status, Is.EqualTo(200));
|
||||||
|
|
||||||
|
var moves = await response.JsonAsync();
|
||||||
|
Assert.That(moves, Is.Not.Null);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Invalid_GameId_Returns_NotFound()
|
||||||
|
{
|
||||||
|
var invalidGameId = Guid.NewGuid().ToString();
|
||||||
|
var response = await _apiContext!.GetAsync($"/api/chess/{invalidGameId}");
|
||||||
|
|
||||||
|
Assert.That(response.Status, Is.EqualTo(404));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
using Microsoft.Playwright.NUnit;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.UiTests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class ChessGameTests : PageTest
|
||||||
|
{
|
||||||
|
private TestConfiguration Config => TestConfiguration.Instance;
|
||||||
|
|
||||||
|
public override BrowserNewContextOptions ContextOptions()
|
||||||
|
{
|
||||||
|
return Config.GetBrowserContextOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
[OneTimeSetUp]
|
||||||
|
public async Task OneTimeSetUp()
|
||||||
|
{
|
||||||
|
// Set Playwright browser options for headless control
|
||||||
|
if (!Config.Playwright.Headless)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("HEADED", "1");
|
||||||
|
}
|
||||||
|
if (Config.Playwright.SlowMotion > 0)
|
||||||
|
{
|
||||||
|
Environment.SetEnvironmentVariable("PWSLOWMO", Config.Playwright.SlowMotion.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[SetUp]
|
||||||
|
public async Task Setup()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async Task WaitDefault()
|
||||||
|
{
|
||||||
|
await Page.WaitForTimeoutAsync(Config.Test.WaitTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Chess_Page_Loads_Successfully()
|
||||||
|
{
|
||||||
|
await Expect(Page.Locator("h1")).ToContainTextAsync("Chess");
|
||||||
|
await Expect(Page.Locator("#startGameBtn")).ToBeVisibleAsync();
|
||||||
|
await Expect(Page.Locator("#startCPUGame")).ToBeVisibleAsync();
|
||||||
|
await Expect(Page.Locator("#chessBoard")).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Chess_Board_Has_64_Squares()
|
||||||
|
{
|
||||||
|
var squares = Page.Locator(".chessSquare");
|
||||||
|
await Expect(squares).ToHaveCountAsync(64);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Start_New_Game_Button_Works()
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
|
||||||
|
// Wait for game to start (pieces should appear)
|
||||||
|
await Page.Locator(".chessPiece").First.WaitForAsync();
|
||||||
|
|
||||||
|
// Check that pieces have been rendered
|
||||||
|
var pieces = Page.Locator(".chessPiece");
|
||||||
|
await Expect(pieces).ToHaveCountAsync(32); // Standard chess has 32 pieces
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Start_CPU_Game_Shows_Difficulty_Modal()
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#startCPUGame");
|
||||||
|
|
||||||
|
// Should show difficulty modal
|
||||||
|
await Expect(Page.Locator("#difficultyModal")).ToBeVisibleAsync();
|
||||||
|
await Expect(Page.Locator("#difficultyModal p")).ToContainTextAsync("Set bot difficulty to:");
|
||||||
|
|
||||||
|
// Should have difficulty buttons 1-20
|
||||||
|
var difficultyButtons = Page.Locator("#difficultyButtonContainer button");
|
||||||
|
await Expect(difficultyButtons).ToHaveCountAsync(20);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task CPU_Game_Starts_After_Selecting_Difficulty()
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#startCPUGame");
|
||||||
|
|
||||||
|
// Select difficulty 5
|
||||||
|
await Page.ClickAsync("#difficultyButtonContainer button:nth-child(5)");
|
||||||
|
|
||||||
|
// Modal should close
|
||||||
|
await Expect(Page.Locator("#difficultyModal")).ToBeHiddenAsync();
|
||||||
|
|
||||||
|
// Wait for game to start
|
||||||
|
await Page.Locator(".chessPiece").First.WaitForAsync();
|
||||||
|
|
||||||
|
// Check that pieces have been rendered
|
||||||
|
var pieces = Page.Locator(".chessPiece");
|
||||||
|
await Expect(pieces).ToHaveCountAsync(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pieces_WhenClicked_HighlightLegalMoves()
|
||||||
|
{
|
||||||
|
// Start a new game first
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
|
||||||
|
// Wait for pieces to be rendered
|
||||||
|
await Page.Locator(".chessPiece").First.WaitForAsync();
|
||||||
|
|
||||||
|
// Find any white piece and click it
|
||||||
|
var pawn = Page.Locator("#chessBoard div:has(img[src*='WhitePawn'])").First;
|
||||||
|
await pawn.ClickAsync();
|
||||||
|
|
||||||
|
var legalMoves = Page.Locator(".legal");
|
||||||
|
await Expect(legalMoves).ToHaveCountAsync(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
using Microsoft.Playwright.NUnit;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.UiTests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class GameplayTests : PageTest
|
||||||
|
{
|
||||||
|
private TestConfiguration Config => TestConfiguration.Instance;
|
||||||
|
|
||||||
|
public override BrowserNewContextOptions ContextOptions()
|
||||||
|
{
|
||||||
|
return Config.GetBrowserContextOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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]
|
||||||
|
public async Task Setup()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Scholars_Mate_Results_In_Checkmate()
|
||||||
|
{
|
||||||
|
// Start a multiplayer game
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
await Page.WaitForSelectorAsync(".chessPiece");
|
||||||
|
|
||||||
|
if (await Page.Locator("#square-55:has(img[src='/images/Chess Images/WhitePawn.svg'])").CountAsync() == 0)
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
await Page.WaitForSelectorAsync(".chessPiece");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a second browser context for the second player
|
||||||
|
await using var browser = await Playwright.Chromium.LaunchAsync();
|
||||||
|
await using var context2 = await browser.NewContextAsync(ContextOptions());
|
||||||
|
var page2 = await context2.NewPageAsync();
|
||||||
|
await page2.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
await page2.ClickAsync("#startGameBtn");
|
||||||
|
await page2.WaitForSelectorAsync(".chessPiece");
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Scholar's Mate sequence:
|
||||||
|
* 1. e4 e5
|
||||||
|
* 2. Bc4 Nc6
|
||||||
|
* 3. Qh5 Nf6??
|
||||||
|
* 4. Qxf7# (checkmate)
|
||||||
|
*
|
||||||
|
* In source square to destination square, accounting for black ui swapping:
|
||||||
|
* 1. 52-36 51-35
|
||||||
|
* 2. 61-34 58-37
|
||||||
|
* 3. 59-45 62-45
|
||||||
|
* 4. 45-13
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Move 1: White plays e4 (pawn e2-e4)
|
||||||
|
await MakeMove(Page, 52, 36); // e2 to e4
|
||||||
|
|
||||||
|
// Move 1: Black plays e5 (pawn e7-e5)
|
||||||
|
await MakeMove(page2, 51, 35); // e7 to e5
|
||||||
|
|
||||||
|
// Move 2: White plays Bc4 (bishop f1-c4)
|
||||||
|
await MakeMove(Page, 61, 34); // f1 to c4
|
||||||
|
|
||||||
|
// Move 2: Black plays Nc6 (knight b8-c6)
|
||||||
|
await MakeMove(page2, 58, 37); // b8 to c6
|
||||||
|
|
||||||
|
// Move 3: White plays Qh5 (queen d1-h5)
|
||||||
|
await MakeMove(Page, 59, 45); // d1 to h5
|
||||||
|
|
||||||
|
// Move 3: Black plays Nf6 (knight g8-f6) - the blunder
|
||||||
|
await MakeMove(page2, 62, 45); // g8 to f6
|
||||||
|
|
||||||
|
string? alertMessage = null;
|
||||||
|
|
||||||
|
Page.Dialog += async (_, dialog) =>
|
||||||
|
{
|
||||||
|
alertMessage = dialog.Message;
|
||||||
|
await dialog.AcceptAsync();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move 4: White plays Qxf7# (queen h5-f7, checkmate)
|
||||||
|
await MakeMove(Page, 45, 13); // h5 to f7
|
||||||
|
|
||||||
|
// The test passes if we can execute all moves without errors
|
||||||
|
// Specific checkmate verification depends on how your UI handles game end
|
||||||
|
Assert.That(alertMessage, Is.Not.Null);
|
||||||
|
Assert.That(alertMessage, Contains.Substring("Checkmate"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Pawn_Promotion_Shows_Modal()
|
||||||
|
{
|
||||||
|
// Start a multiplayer game
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
await Page.WaitForSelectorAsync(".chessPiece");
|
||||||
|
|
||||||
|
if (await Page.Locator("#square-55:has(img[src='/images/Chess Images/WhitePawn.svg'])").CountAsync() == 0)
|
||||||
|
{
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
await Page.WaitForSelectorAsync(".chessPiece");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a second browser context for the second player (black)
|
||||||
|
await using var browser = await Playwright.Chromium.LaunchAsync();
|
||||||
|
await using var context2 = await browser.NewContextAsync(ContextOptions());
|
||||||
|
var page2 = await context2.NewPageAsync();
|
||||||
|
await page2.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
await page2.ClickAsync("#startGameBtn");
|
||||||
|
await page2.WaitForSelectorAsync(".chessPiece");
|
||||||
|
|
||||||
|
// Exact sequence: 1. h4 g5 2. hxg5 h6 3. gxh6 a6 4. h7 a5 5. hxg8=Q
|
||||||
|
// in square numbers: 55-39 49-33 39-30 48-40 30-23 55-47 23-15 47-39 15-6
|
||||||
|
|
||||||
|
// Move 1: White h4 (h2-h4, square 55 to 39)
|
||||||
|
await MakeMove(Page, 55, 39); // h2 to h4
|
||||||
|
|
||||||
|
// Move 1: Black g5 (g7-g5, square 14 to 30)
|
||||||
|
await MakeMove(page2, 49, 33); // g7 to g5
|
||||||
|
|
||||||
|
// Move 2: White hxg5 (h4 captures g5, square 39 to 30)
|
||||||
|
await MakeMove(Page, 39, 30); // h4 captures g5
|
||||||
|
|
||||||
|
// Move 2: Black h6 (h7-h6, square 15 to 23)
|
||||||
|
await MakeMove(page2, 48, 40); // h7 to h6
|
||||||
|
|
||||||
|
// Move 3: White gxh6 (g5 captures h6, square 30 to 23)
|
||||||
|
await MakeMove(Page, 30, 23); // g5 captures h6
|
||||||
|
|
||||||
|
// Move 3: Black a6 (a7-a6, square 8 to 16)
|
||||||
|
await MakeMove(page2, 55, 47); // a7 to a6
|
||||||
|
|
||||||
|
// Move 4: White h7 (h6-h7, square 23 to 15)
|
||||||
|
await MakeMove(Page, 23, 15); // h6 to h7
|
||||||
|
|
||||||
|
// Move 4: Black a5 (a6-a5, square 16 to 24)
|
||||||
|
await MakeMove(page2, 47, 39); // a6 to a5
|
||||||
|
|
||||||
|
// Move 5: White hxg8=Q (h7 captures g8 and promotes, square 15 to 6)
|
||||||
|
await MakeMove(Page, 15, 6); // h7 captures g8 (promotion)
|
||||||
|
|
||||||
|
// Check if promotion modal appears
|
||||||
|
var promotionModal = Page.Locator("#promotionModal");
|
||||||
|
var queenOption = Page.Locator("button:has(img[alt='Queen'])");
|
||||||
|
var promotedQueenSquare = Page.Locator("#square-6:has(img[src='/images/Chess Images/WhiteQueen.svg'])");
|
||||||
|
var isModalVisible = await promotionModal.IsVisibleAsync();
|
||||||
|
|
||||||
|
await Expect(promotionModal).ToBeVisibleAsync();
|
||||||
|
await Expect(queenOption).ToBeVisibleAsync();
|
||||||
|
|
||||||
|
await queenOption.ClickAsync();
|
||||||
|
|
||||||
|
await promotionModal.WaitForAsync(new() { State = WaitForSelectorState.Hidden });
|
||||||
|
await Expect(promotedQueenSquare).ToBeVisibleAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task MakeMove(IPage page, int fromSquare, int toSquare)
|
||||||
|
{
|
||||||
|
// Click on the source square/piece
|
||||||
|
var fromSquareElement = page.Locator($"#square-{fromSquare}");
|
||||||
|
var piece = fromSquareElement.Locator(".chessPiece");
|
||||||
|
|
||||||
|
if (await piece.CountAsync() > 0)
|
||||||
|
{
|
||||||
|
await piece.ClickAsync();
|
||||||
|
await page.WaitForTimeoutAsync(500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on the destination square
|
||||||
|
var toSquareElement = page.Locator($"#square-{toSquare}");
|
||||||
|
await toSquareElement.ClickAsync();
|
||||||
|
await page.WaitForTimeoutAsync(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
|
||||||
|
<IsPackable>false</IsPackable>
|
||||||
|
<IsTestProject>true</IsTestProject>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="Microsoft.Playwright.NUnit" Version="1.27.1" />
|
||||||
|
<PackageReference Include="NUnit" Version="3.14.0" />
|
||||||
|
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
|
||||||
|
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Using Include="Microsoft.Playwright.NUnit" />
|
||||||
|
<Using Include="NUnit.Framework" />
|
||||||
|
<Using Include="System.Text.RegularExpressions" />
|
||||||
|
<Using Include="System.Threading.Tasks" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Content Include="appsettings.Integration.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
<Content Include="appsettings.CI.json">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</Content>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.Playwright;
|
||||||
|
using Microsoft.Playwright.NUnit;
|
||||||
|
using NUnit.Framework;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.UiTests;
|
||||||
|
|
||||||
|
[TestFixture]
|
||||||
|
public class MultiplayerTests : PageTest
|
||||||
|
{
|
||||||
|
private TestConfiguration Config => TestConfiguration.Instance;
|
||||||
|
|
||||||
|
public override BrowserNewContextOptions ContextOptions()
|
||||||
|
{
|
||||||
|
return Config.GetBrowserContextOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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]
|
||||||
|
public async Task Setup()
|
||||||
|
{
|
||||||
|
await Page.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
await Page.WaitForLoadStateAsync(LoadState.NetworkIdle);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Two_Players_Can_Join_Same_Game()
|
||||||
|
{
|
||||||
|
// This test requires running two browser contexts
|
||||||
|
await using var browser = await Playwright.Chromium.LaunchAsync();
|
||||||
|
await using var context1 = await browser.NewContextAsync(ContextOptions());
|
||||||
|
await using var context2 = await browser.NewContextAsync(ContextOptions());
|
||||||
|
|
||||||
|
var page1 = await context1.NewPageAsync();
|
||||||
|
var page2 = await context2.NewPageAsync();
|
||||||
|
|
||||||
|
await page1.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
await page2.GotoAsync($"{Config.Test.BaseUrl}/chess");
|
||||||
|
|
||||||
|
// Player 1 starts a game
|
||||||
|
await page1.ClickAsync("#startGameBtn");
|
||||||
|
await page1.Locator(".chessPiece").First.WaitForAsync();
|
||||||
|
|
||||||
|
// Player 2 joins the same game
|
||||||
|
await page2.ClickAsync("#startGameBtn");
|
||||||
|
await page2.Locator(".chessPiece").First.WaitForAsync();
|
||||||
|
|
||||||
|
// Both players should see pieces
|
||||||
|
var pieces1 = page1.Locator(".chessPiece");
|
||||||
|
var pieces2 = page2.Locator(".chessPiece");
|
||||||
|
|
||||||
|
await Expect(pieces1).ToHaveCountAsync(32);
|
||||||
|
await Expect(pieces2).ToHaveCountAsync(32);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task SignalR_Connection_Established()
|
||||||
|
{
|
||||||
|
// Start a game to trigger SignalR connection
|
||||||
|
await Page.ClickAsync("#startGameBtn");
|
||||||
|
await Page.Locator(".chessPiece").First.WaitForAsync();
|
||||||
|
|
||||||
|
// Check browser console for SignalR connection message
|
||||||
|
var consoleLogs = new List<string>();
|
||||||
|
Page.Console += (_, e) => consoleLogs.Add(e.Text);
|
||||||
|
|
||||||
|
await Page.ReloadAsync();
|
||||||
|
await Page.WaitForTimeoutAsync(1000);
|
||||||
|
|
||||||
|
// Should see SignalR connected message in console
|
||||||
|
Assert.That(consoleLogs, Does.Contain("✅ SignalR connected").Or.Contain("SignalR connected"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# JoshHeaps.Net UI Tests
|
||||||
|
|
||||||
|
This project contains end-to-end tests for the JoshHeaps.Net website using Playwright.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install Playwright browsers:
|
||||||
|
```bash
|
||||||
|
# From the test project directory
|
||||||
|
dotnet build
|
||||||
|
# Then install browsers (requires PowerShell or appropriate command for your OS)
|
||||||
|
bin/Debug/net8.0/playwright.cmd install # Windows
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Make sure the main application is running on `https://localhost:7065`
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
dotnet test
|
||||||
|
|
||||||
|
# Run specific test file
|
||||||
|
dotnet test --filter "FullyQualifiedName~ChessGameTests"
|
||||||
|
|
||||||
|
# Run with verbose output
|
||||||
|
dotnet test --logger "console;verbosity=detailed"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Categories
|
||||||
|
|
||||||
|
### ChessGameTests
|
||||||
|
- Basic chess page functionality
|
||||||
|
- Game start/stop operations
|
||||||
|
- Piece interaction and move validation
|
||||||
|
- UI element verification
|
||||||
|
|
||||||
|
### MultiplayerTests
|
||||||
|
- Two-player game scenarios
|
||||||
|
- SignalR connection testing
|
||||||
|
- Real-time move synchronization
|
||||||
|
|
||||||
|
### ApiTests
|
||||||
|
- Chess API endpoint testing
|
||||||
|
- Game creation and state management
|
||||||
|
- Move validation at API level
|
||||||
|
- Error handling
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
The tests are configured to:
|
||||||
|
- Use headless browsers by default
|
||||||
|
- Take screenshots on failure
|
||||||
|
- Record videos on failure
|
||||||
|
- Automatically start the web server if needed (port 7065)
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Tests expect the main application to be available at `https://localhost:7065`
|
||||||
|
- Some tests require browsers to be installed via Playwright CLI
|
||||||
|
- Browser installations may require PowerShell on Windows systems
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Playwright;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.UiTests;
|
||||||
|
|
||||||
|
public class TestConfiguration
|
||||||
|
{
|
||||||
|
private static TestConfiguration? _instance;
|
||||||
|
private static readonly object _lock = new object();
|
||||||
|
|
||||||
|
public PlaywrightSettings Playwright { get; }
|
||||||
|
public TestSettings Test { get; }
|
||||||
|
|
||||||
|
private TestConfiguration()
|
||||||
|
{
|
||||||
|
var isCI = Environment.GetEnvironmentVariable("CI") == "true" ||
|
||||||
|
Environment.GetEnvironmentVariable("GITHUB_ACTIONS") == "true";
|
||||||
|
|
||||||
|
var configFile = isCI ? "appsettings.CI.json" : "appsettings.Integration.json";
|
||||||
|
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddJsonFile(configFile, optional: true, reloadOnChange: false)
|
||||||
|
.AddJsonFile("appsettings.Integration.json", optional: true, reloadOnChange: false) // fallback
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
Playwright = configuration.GetSection("PlaywrightSettings").Get<PlaywrightSettings>() ?? new PlaywrightSettings();
|
||||||
|
Test = configuration.GetSection("TestSettings").Get<TestSettings>() ?? new TestSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static TestConfiguration Instance
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
if (_instance == null)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
if (_instance == null)
|
||||||
|
{
|
||||||
|
_instance = new TestConfiguration();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return _instance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowserNewContextOptions GetBrowserContextOptions()
|
||||||
|
{
|
||||||
|
return new BrowserNewContextOptions
|
||||||
|
{
|
||||||
|
ViewportSize = new ViewportSize
|
||||||
|
{
|
||||||
|
Width = Playwright.Viewport.Width,
|
||||||
|
Height = Playwright.Viewport.Height
|
||||||
|
},
|
||||||
|
RecordVideoDir = Playwright.EnableVideoRecording ? Path.Combine(Directory.GetCurrentDirectory(), "test-results", "videos") : null,
|
||||||
|
IgnoreHTTPSErrors = Playwright.IgnoreHTTPSErrors
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public BrowserTypeLaunchOptions GetLaunchOptions()
|
||||||
|
{
|
||||||
|
return new BrowserTypeLaunchOptions
|
||||||
|
{
|
||||||
|
Headless = Playwright.Headless,
|
||||||
|
SlowMo = Playwright.SlowMotion,
|
||||||
|
Timeout = Playwright.BrowserTimeout
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PlaywrightSettings
|
||||||
|
{
|
||||||
|
public bool Headless { get; set; } = true;
|
||||||
|
public float SlowMotion { get; set; } = 0;
|
||||||
|
public ViewportSettings Viewport { get; set; } = new();
|
||||||
|
public float BrowserTimeout { get; set; } = 30000;
|
||||||
|
public float ActionTimeout { get; set; } = 10000;
|
||||||
|
public bool EnableVideoRecording { get; set; } = false;
|
||||||
|
public bool IgnoreHTTPSErrors { get; set; } = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ViewportSettings
|
||||||
|
{
|
||||||
|
public int Width { get; set; } = 1280;
|
||||||
|
public int Height { get; set; } = 720;
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TestSettings
|
||||||
|
{
|
||||||
|
public string BaseUrl { get; set; } = "https://localhost:7118";
|
||||||
|
public int WaitTimeout { get; set; } = 3000;
|
||||||
|
public bool EnableScreenshots { get; set; } = true;
|
||||||
|
public bool EnableVideoRecording { get; set; } = false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"PlaywrightSettings": {
|
||||||
|
"Headless": true,
|
||||||
|
"SlowMotion": 0,
|
||||||
|
"Viewport": {
|
||||||
|
"Width": 1280,
|
||||||
|
"Height": 720
|
||||||
|
},
|
||||||
|
"BrowserTimeout": 60000,
|
||||||
|
"ActionTimeout": 30000,
|
||||||
|
"EnableVideoRecording": true,
|
||||||
|
"IgnoreHTTPSErrors": true
|
||||||
|
},
|
||||||
|
"TestSettings": {
|
||||||
|
"BaseUrl": "https://localhost:7118",
|
||||||
|
"WaitTimeout": 5000,
|
||||||
|
"EnableScreenshots": true,
|
||||||
|
"EnableVideoRecording": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"PlaywrightSettings": {
|
||||||
|
"Headless": false,
|
||||||
|
"SlowMotion": 500,
|
||||||
|
"Viewport": {
|
||||||
|
"Width": 1280,
|
||||||
|
"Height": 720
|
||||||
|
},
|
||||||
|
"BrowserTimeout": 30000,
|
||||||
|
"ActionTimeout": 10000,
|
||||||
|
"IgnoreHTTPSErrors": true
|
||||||
|
},
|
||||||
|
"TestSettings": {
|
||||||
|
"BaseUrl": "https://localhost:7118",
|
||||||
|
"WaitTimeout": 3000,
|
||||||
|
"EnableScreenshots": true,
|
||||||
|
"EnableVideoRecording": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"use": {
|
||||||
|
"headless": true,
|
||||||
|
"viewport": { "width": 1280, "height": 720 },
|
||||||
|
"ignoreHTTPSErrors": true,
|
||||||
|
"video": "retain-on-failure",
|
||||||
|
"screenshot": "only-on-failure"
|
||||||
|
},
|
||||||
|
"projects": [
|
||||||
|
{
|
||||||
|
"name": "chromium",
|
||||||
|
"use": { "browserName": "chromium" }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "firefox",
|
||||||
|
"use": { "browserName": "firefox" }
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"webServer": {
|
||||||
|
"command": "dotnet run --project ../JoshHeaps.Net",
|
||||||
|
"port": 7065,
|
||||||
|
"reuseExistingServer": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ VisualStudioVersion = 17.9.34728.123
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net", "JoshHeaps.Net\JoshHeaps.Net.csproj", "{9F0182CC-470F-4D1A-99F5-348D7921751E}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{82206AD3-19DF-4DDA-9647-02B691B78CE8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JoshHeaps.Net.UiTests", "JoshHeaps.Net.UiTests\JoshHeaps.Net.UiTests.csproj", "{360264F4-8292-4EB3-B67D-98376C13438B}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
@@ -17,10 +17,10 @@ Global
|
|||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
{9F0182CC-470F-4D1A-99F5-348D7921751E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{82206AD3-19DF-4DDA-9647-02B691B78CE8}.Release|Any CPU.Build.0 = Release|Any CPU
|
{360264F4-8292-4EB3-B67D-98376C13438B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,14 +9,21 @@ namespace JoshHeaps.Net.Controllers;
|
|||||||
|
|
||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/[controller]")]
|
[Route("api/[controller]")]
|
||||||
public class ChessController(IChessService chessService, IHubContext<ChessHub> chessHub, IBackgroundTaskQueue queue) : ControllerBase
|
public class ChessController(
|
||||||
|
IChessService chessService,
|
||||||
|
IHubContext<ChessHub> chessHub,
|
||||||
|
IBackgroundTaskQueue queue) : ControllerBase
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Store of ongoing games.
|
/// Store of ongoing games.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
private static readonly ConcurrentDictionary<Guid, GameState> _games = [];
|
||||||
|
private static readonly ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
||||||
|
private static readonly ConcurrentDictionary<Guid, CancellationTokenSource> _gameRemovalCancellationTokens = [];
|
||||||
|
|
||||||
private static ConcurrentDictionary<Guid, Task> _gameRemovalTasks = [];
|
private static readonly TimeSpan _computerGameTimeout = TimeSpan.FromHours(1);
|
||||||
|
private static readonly TimeSpan _multiplayerGameTimeout = TimeSpan.FromDays(1);
|
||||||
|
private static readonly TimeSpan _gameCleanupTimeout = TimeSpan.FromMinutes(1);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Create a new chess game and store it in-memory.
|
/// Create a new chess game and store it in-memory.
|
||||||
@@ -54,7 +61,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -96,7 +103,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
isWhite = false;
|
isWhite = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
@@ -158,7 +165,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
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);
|
||||||
@@ -167,7 +174,7 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
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);
|
||||||
|
|
||||||
@@ -175,18 +182,11 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
return BadRequest(result);
|
return BadRequest(result);
|
||||||
|
|
||||||
if (result.IsCheckmate || result.IsStalemate)
|
if (result.IsCheckmate || result.IsStalemate)
|
||||||
{
|
ScheduleRemoveGame(gameState.GameId, _gameCleanupTimeout);
|
||||||
// queue game removal
|
else if (gameState.IsVsComputer)
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromMinutes(1));
|
ScheduleRemoveGame(gameState.GameId, _computerGameTimeout);
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
ScheduleRemoveGame(gameState.GameId, _multiplayerGameTimeout);
|
||||||
// increase timeout if play continues.
|
|
||||||
if (gameState.IsVsComputer)
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromHours(1));
|
|
||||||
else
|
|
||||||
ScheduleRemoveGame(gameState.GameId, TimeSpan.FromDays(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameState.IsVsComputer && gameState.Computer is not null)
|
if (gameState.IsVsComputer && gameState.Computer is not null)
|
||||||
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
queue.Queue(() => gameState.Computer.MakeMove(gameState, chessHub, chessService));
|
||||||
@@ -229,31 +229,36 @@ public class ChessController(IChessService chessService, IHubContext<ChessHub> c
|
|||||||
|
|
||||||
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
private static void ScheduleRemoveGame(Guid id, TimeSpan delay)
|
||||||
{
|
{
|
||||||
if (_gameRemovalTasks.ContainsKey(id))
|
if (_gameRemovalCancellationTokens.TryRemove(id, out var oldCts))
|
||||||
{
|
{
|
||||||
_gameRemovalTasks[id] = Task.Run(async () =>
|
oldCts.Cancel();
|
||||||
{
|
oldCts.Dispose();
|
||||||
await Task.Delay(delay);
|
|
||||||
|
|
||||||
if (_games[id].Computer is not null)
|
|
||||||
await _games[id].Computer!.DisposeAsync();
|
|
||||||
|
|
||||||
_games.Remove(id, out _);
|
|
||||||
_gameRemovalTasks.Remove(id, out _);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_gameRemovalTasks.TryAdd(id, Task.Run(async () =>
|
var cts = new CancellationTokenSource();
|
||||||
|
_gameRemovalCancellationTokens[id] = cts;
|
||||||
|
|
||||||
|
_gameRemovalTasks[id] = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
await Task.Delay(delay);
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(delay, cts.Token);
|
||||||
|
|
||||||
if (_games[id].Computer is not null)
|
if (_games.TryGetValue(id, out var game) && game.Computer is not null)
|
||||||
await _games[id].Computer!.DisposeAsync();
|
await game.Computer.DisposeAsync();
|
||||||
|
|
||||||
_games.Remove(id, out _);
|
_games.Remove(id, out _);
|
||||||
_gameRemovalTasks.Remove(id, out _);
|
}
|
||||||
}));
|
catch (OperationCanceledException) { }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (_gameRemovalCancellationTokens.TryGetValue(id, out var currentCts) && currentCts == cts)
|
||||||
|
{
|
||||||
|
_gameRemovalCancellationTokens.TryRemove(id, out _);
|
||||||
|
}
|
||||||
|
|
||||||
|
cts.Dispose();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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">← 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>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,7 +59,14 @@
|
|||||||
|
|
||||||
@section Scripts {
|
@section Scripts {
|
||||||
<script src="~/js/signalr/signalr.min.js"></script>
|
<script src="~/js/signalr/signalr.min.js"></script>
|
||||||
<script src="~/js/ChessScripts/chessLogic.js"></script>
|
<script src="~/js/ChessScripts/GameState.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessUtils.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessAPI.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessSignalR.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessInteractions.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessBoard.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/ChessModals.js"></script>
|
||||||
|
<script src="~/js/ChessScripts/chessMain.js"></script>
|
||||||
}
|
}
|
||||||
|
|
||||||
@section Styles {
|
@section Styles {
|
||||||
|
|||||||
@@ -1,95 +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>
|
||||||
<div id="WelcomeMessage"></div>
|
<a class="nav-link" onclick="showClickedContents('contact')">Contact</a>
|
||||||
|
|
||||||
<div id="ProjectsBox">
|
|
||||||
<div id="ChessProject" class="projectContainer">
|
|
||||||
<div class="displayBox diagonal-section-left">
|
|
||||||
<a target="_blank" href="https://github.com/JoshHeaps/OnlineChess">
|
|
||||||
<img id="ChessImage" class="projectImage projectImageLeft" src="/images/Chess.jpg" title="Chessboard"/>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div id="ChessText" class="projectTextRight">
|
|
||||||
<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>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div id="CompilerProject" class="projectContainer">
|
<section id="Hero">
|
||||||
<div id="CompilerText" class="projectTextLeft">
|
<div id="WelcomeMessage"></div>
|
||||||
<h2 id="CompilerTitle" class="projectHeaderLeft projectHeader">Compiler</h2>
|
</section>
|
||||||
<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.
|
<section id="ProjectsBox">
|
||||||
</p>
|
<h2 class="section-title">Projects</h2>
|
||||||
</div>
|
<div class="projects-grid">
|
||||||
<div class="displayBox diagonal-section-right">
|
<article class="project-card">
|
||||||
<a target="_blank" href="https://github.com/JoshHeaps/CILCompiler">
|
<a href="https://github.com/JoshHeaps/JoshHeaps.Net/blob/master/JoshHeaps.Net/Pages/Chess.cshtml" target="_blank" class="project-image-link">
|
||||||
<img id="CompilerImage" class="projectImage projectImageRight" src="/images/CompilerDemo.png" title="Compiler Input and Output"/>
|
<img class="projectImage" src="/images/Chess.jpg" alt="Chessboard" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<div class="project-info">
|
||||||
</div>
|
<h3 class="project-title">Chess</h3>
|
||||||
</div>
|
<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 →</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
<div id="DemosBox" class="displayBox">
|
<article class="project-card">
|
||||||
<h2 id="DemoHeader">Demos</h2>
|
<a href="https://github.com/JoshHeaps/CILCompiler" target="_blank" class="project-image-link">
|
||||||
|
<img class="projectImage" src="/images/CompilerDemo.png" alt="Compiler Input and Output" />
|
||||||
|
</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 →</a>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="project-card">
|
||||||
|
<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 →</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="ChessDemo" 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="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 →</a>
|
||||||
|
</section>
|
||||||
|
|
||||||
<footer id="Contacts">
|
<footer id="Contacts">
|
||||||
<svg viewBox="0 0 16 16" class="social-icon">
|
<a class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
||||||
<a id="GithubLink" class="social-link" href="https://github.com/JoshHeaps" target="_blank">
|
<svg viewBox="0 0 16 16" class="social-icon">
|
||||||
<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" />
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</svg>
|
<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" />
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</svg>
|
<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" />
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
</svg>
|
|
||||||
</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" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,4 +117,4 @@
|
|||||||
<script src="/js/site.js"></script>
|
<script src="/js/site.js"></script>
|
||||||
<script src="/js/utils.js"></script>
|
<script src="/js/utils.js"></script>
|
||||||
<script src="/js/opacityUpdateObserver.js"></script>
|
<script src="/js/opacityUpdateObserver.js"></script>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.MemoryLaneModel
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Memory Lane";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="memory-lane-container">
|
||||||
|
<h1 class="memory-lane-header">Memory Lane</h1>
|
||||||
|
<p class="memory-lane-subtitle">Special moments and celebrations from our life together</p>
|
||||||
|
|
||||||
|
<div class="events-grid">
|
||||||
|
<a href="/memoryLane/fifthTempleAnniversary" class="event-card">
|
||||||
|
<div class="event-icon">💝</div>
|
||||||
|
<div class="event-title">Our 5th Sealing Anniversary</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Celebrating the fifth year of our eternal journey together
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="event-card disabled">
|
||||||
|
<div class="event-icon">🎂</div>
|
||||||
|
<div class="event-title">Birthdays & Births</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Welcoming new life and celebrating another year
|
||||||
|
</div>
|
||||||
|
<div class="coming-soon">Coming soon...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card disabled">
|
||||||
|
<div class="event-icon">🌟</div>
|
||||||
|
<div class="event-title">Milestones</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Big achievements and unforgettable moments
|
||||||
|
</div>
|
||||||
|
<div class="coming-soon">Coming soon...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="event-card disabled">
|
||||||
|
<div class="event-icon">✨</div>
|
||||||
|
<div class="event-title">More Memories</div>
|
||||||
|
<div class="event-description">
|
||||||
|
Even more special occasions and celebrations
|
||||||
|
</div>
|
||||||
|
<div class="coming-soon">Coming soon...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<a href="/" class="home-button">← Back to Home</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link href="/css/memoryLane/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages
|
||||||
|
{
|
||||||
|
public class MemoryLaneModel() : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
@page
|
||||||
|
@model JoshHeaps.Net.Pages.MemoryLane.FifthTempleAnniversary
|
||||||
|
@{
|
||||||
|
ViewData["Title"] = "Happy 5th Anniversary, Morgan!";
|
||||||
|
Layout = "_Layout";
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="anniversary-container">
|
||||||
|
<h1 class="anniversary-header">Happy 5th Anniversary, Morgan!</h1>
|
||||||
|
|
||||||
|
<div id="WelcomeMessage"></div>
|
||||||
|
|
||||||
|
<div class="hearts">💖 💕 💗</div>
|
||||||
|
|
||||||
|
<div class="photo-gallery">
|
||||||
|
<div class="photo-frame">
|
||||||
|
<div class="photo-wrapper">
|
||||||
|
<img src="/images/Wife Images/45053.jpeg" alt="Morgan with our beautiful baby">
|
||||||
|
</div>
|
||||||
|
<div class="photo-caption">The most amazing mother to our precious child</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="photo-frame">
|
||||||
|
<div class="photo-wrapper">
|
||||||
|
<img src="/images/Wife Images/45054.jpeg" alt="Josh and Morgan together">
|
||||||
|
</div>
|
||||||
|
<div class="photo-caption">My best friend and eternal companion</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="love-message">
|
||||||
|
<h2>Five Years of Forever</h2>
|
||||||
|
<p>
|
||||||
|
Five years ago today, on October 2nd, 2020, I knelt across the altar from you in the temple
|
||||||
|
and we were sealed for time and all eternity. It was the best decision I ever made.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Morgan, you are everything I could have ever hoped for and so much more. You're not just
|
||||||
|
my wife—you're my best friend, my partner in every adventure, and the love of my life.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You make me laugh every single day. Your kindness touches everyone around you. Your
|
||||||
|
strength amazes me. Your beauty, inside and out, takes my breath away. Watching you
|
||||||
|
become a mother has shown me depths of love and dedication I never knew existed.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Thank you for five incredible years of love, laughter, growth, and joy. Thank you for
|
||||||
|
choosing me, for believing in me, and for building this beautiful life with me.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Here's to five years down and an eternity to go. I love you more today than yesterday,
|
||||||
|
and I'll love you even more tomorrow.
|
||||||
|
</p>
|
||||||
|
<p style="font-size: 1.5rem; margin-top: 2rem; color: #764ba2;">
|
||||||
|
<strong>I love you, Morgan. Forever and always. 💖</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hearts">💝 💞 💓</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@section Styles {
|
||||||
|
<link href="/css/anniversary/page.css?v=@ViewData["cssVersion"]" rel="stylesheet" type="text/css" />
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script src="/js/anniversary/page.js"></script>
|
||||||
|
<script src="/js/utils.js"></script>
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc.RazorPages;
|
||||||
|
|
||||||
|
namespace JoshHeaps.Net.Pages.MemoryLane
|
||||||
|
{
|
||||||
|
public class FifthTempleAnniversary() : PageModel
|
||||||
|
{
|
||||||
|
public void OnGet()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) { /* shutting down */ }
|
catch (OperationCanceledException) { break; }
|
||||||
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,7 +528,14 @@ 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)
|
||||||
moves.Add(ep);
|
{
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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,5 +5,9 @@
|
|||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*",
|
||||||
|
"BlogApi": {
|
||||||
|
"BaseUrl": "https://media.joshheaps.net",
|
||||||
|
"InvalidateKey": "CHANGE_ME"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
body {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-container {
|
||||||
|
max-width: 1400px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 3rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-header {
|
||||||
|
color: white;
|
||||||
|
font-size: 3.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||||
|
animation: fadeInDown 1s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
#WelcomeMessage {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2rem 2.5rem;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
max-width: 900px;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
line-height: 1.8;
|
||||||
|
color: #333;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
animation: fadeIn 2s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hearts {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
animation: heartbeat 1.5s ease-in-out infinite;
|
||||||
|
width: fit-content;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 1200px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame {
|
||||||
|
background: white;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 15px 40px rgba(0,0,0,0.3);
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
animation: fadeInUp 1.5s ease-out;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame:hover {
|
||||||
|
transform: translateY(-10px);
|
||||||
|
box-shadow: 0 20px 50px rgba(0,0,0,0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 500px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-frame:nth-child(2) img {
|
||||||
|
object-position: center 30%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-caption {
|
||||||
|
margin-top: 1rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: #666;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message {
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 2.5rem 3rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
max-width: 1000px;
|
||||||
|
color: #444;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
animation: fadeIn 2.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message h2 {
|
||||||
|
color: #764ba2;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message p {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.9;
|
||||||
|
margin-bottom: 1.2rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message p:last-of-type {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes heartbeat {
|
||||||
|
0%, 100% {
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.photo-gallery {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wrapper {
|
||||||
|
height: 450px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.anniversary-container {
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anniversary-header {
|
||||||
|
font-size: 2.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#WelcomeMessage {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-wrapper {
|
||||||
|
height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message h2 {
|
||||||
|
font-size: 1.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.love-message p {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hearts {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -158,4 +163,30 @@
|
|||||||
width: 50px;
|
width: 50px;
|
||||||
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;
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
body {
|
||||||
|
background: #1a1a1a;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-container {
|
||||||
|
max-width: 1000px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 4rem 2rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-header {
|
||||||
|
color: #f5f5f5;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 300;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-subtitle {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: 4rem;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||||
|
gap: 1.5rem;
|
||||||
|
margin: 3rem auto;
|
||||||
|
max-width: 900px;
|
||||||
|
padding: 0 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 2rem 1.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card:hover {
|
||||||
|
background: #2f2f2f;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-card.disabled:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #3a3a3a;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-icon {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: 400;
|
||||||
|
color: #f5f5f5;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #999;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.coming-soon {
|
||||||
|
color: #666;
|
||||||
|
font-style: italic;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button {
|
||||||
|
margin-top: 4rem;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: transparent;
|
||||||
|
color: #f5f5f5;
|
||||||
|
border: 1px solid #3a3a3a;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button:hover {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border-color: #4a4a4a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInDown {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(50px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.memory-lane-header {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.memory-lane-subtitle {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.events-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-title {
|
||||||
|
font-size: 1.6rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 107 KiB After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 275 KiB |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 98 KiB |
@@ -0,0 +1,113 @@
|
|||||||
|
const ChessAPI = {
|
||||||
|
async joinGame() {
|
||||||
|
const response = await fetch('/api/chess/JoinGame');
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCPUGame(difficulty) {
|
||||||
|
const response = await fetch(`/api/chess/new/${difficulty}`);
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getGameState(gameId) {
|
||||||
|
const response = await fetch(`/api/chess/${gameId}`);
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLegalMoves(pieceId) {
|
||||||
|
const response = await fetch(`/api/chess/${GameState.currentGameId}/legalMoves/${pieceId}`);
|
||||||
|
if (!response.ok) throw new Error("API failed");
|
||||||
|
return await response.json();
|
||||||
|
},
|
||||||
|
|
||||||
|
async makeMove(moveDto) {
|
||||||
|
const response = await fetch("/api/chess/move", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
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();
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.message || "Invalid move or not your turn.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleMove(targetRow, targetCol) {
|
||||||
|
if (!GameState.selectedPiece) return;
|
||||||
|
|
||||||
|
const isPawn = GameState.selectedPiece.type === 0;
|
||||||
|
const reachedEnd = (Number(GameState.selectedPiece.color) === 0 && Number(targetRow) === 0) ||
|
||||||
|
(Number(GameState.selectedPiece.color) === 1 && Number(targetRow) === 0);
|
||||||
|
|
||||||
|
let choice = null;
|
||||||
|
|
||||||
|
if (isPawn && reachedEnd) {
|
||||||
|
choice = await ChessModals.promptPromotion();
|
||||||
|
if (!choice) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
[targetRow, targetCol] = ChessUtils.flipCoordinates(targetRow, targetCol);
|
||||||
|
|
||||||
|
const moveDto = {
|
||||||
|
GameId: GameState.currentGameId,
|
||||||
|
PlayerId: GameState.currentPlayerId,
|
||||||
|
PieceId: GameState.selectedPiece.id,
|
||||||
|
SourceRow: GameState.selectedPiece.row,
|
||||||
|
SourceCol: GameState.selectedPiece.col,
|
||||||
|
TargetRow: targetRow,
|
||||||
|
TargetCol: targetCol,
|
||||||
|
PromotionChoice: choice
|
||||||
|
};
|
||||||
|
|
||||||
|
GameState.setPreviousMove([GameState.selectedPiece.row, GameState.selectedPiece.col], [targetRow, targetCol]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moveResult = await this.makeMove(moveDto);
|
||||||
|
|
||||||
|
const updatedGame = await this.getGameState(GameState.currentGameId);
|
||||||
|
ChessBoard.renderPieces(updatedGame.pieces);
|
||||||
|
|
||||||
|
GameState.clearSelection();
|
||||||
|
ChessInteractions.clearHighlights();
|
||||||
|
|
||||||
|
await ChessSignalR.notifyMoveMade(moveDto, moveResult);
|
||||||
|
this.alertGameStatusChange(moveResult);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
alert("❌ " + error.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
alertGameStatusChange(moveResult) {
|
||||||
|
setTimeout(async () => {
|
||||||
|
let gameOver = false;
|
||||||
|
|
||||||
|
if (moveResult.isCheck) {
|
||||||
|
console.log("🛑 Check!");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (moveResult.isCheckmate) {
|
||||||
|
alert("♟️ Checkmate!");
|
||||||
|
gameOver = true;
|
||||||
|
} else if (moveResult.isStalemate) {
|
||||||
|
alert("🤝 Stalemate!");
|
||||||
|
gameOver = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gameOver) {
|
||||||
|
await ChessSignalR.leaveGame();
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessAPI.js loaded");
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
const ChessBoard = {
|
||||||
|
renderPieces(pieces) {
|
||||||
|
this.clearAllSquares();
|
||||||
|
this.renderCoordinateLabels();
|
||||||
|
this.placePieces(pieces);
|
||||||
|
this.setupSquareEventHandlers();
|
||||||
|
this.highlightPreviousMove();
|
||||||
|
},
|
||||||
|
|
||||||
|
clearAllSquares() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
square.innerHTML = "";
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
placePieces(pieces) {
|
||||||
|
pieces.forEach(piece => {
|
||||||
|
const [r, c] = ChessUtils.flipCoordinates(piece.row, piece.col);
|
||||||
|
const index = r * 8 + c;
|
||||||
|
const square = document.getElementById(`square-${index}`);
|
||||||
|
if (!square) return;
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.src = ChessUtils.getPieceImageUrl(piece);
|
||||||
|
img.alt = piece.type;
|
||||||
|
img.classList.add("chessPiece");
|
||||||
|
|
||||||
|
this.setupPieceDragEvents(img, piece);
|
||||||
|
this.setupPieceClickEvent(img, piece);
|
||||||
|
|
||||||
|
square.appendChild(img);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
setupPieceDragEvents(img, piece) {
|
||||||
|
img.draggable = true;
|
||||||
|
img.ondragstart = async (e) => {
|
||||||
|
GameState.setSelectedPiece(piece);
|
||||||
|
try {
|
||||||
|
const moves = await ChessAPI.getLegalMoves(piece.id);
|
||||||
|
GameState.setLegalMoves(moves);
|
||||||
|
ChessInteractions.highlightSelected(piece.row, piece.col);
|
||||||
|
ChessInteractions.highlightLegalMoves(moves);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
|
||||||
|
e.dataTransfer.setData("text/plain", JSON.stringify({
|
||||||
|
srcRow: piece.Row,
|
||||||
|
srcCol: piece.Col
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
img.ondragend = () => ChessInteractions.clearHighlights();
|
||||||
|
},
|
||||||
|
|
||||||
|
setupPieceClickEvent(img, piece) {
|
||||||
|
img.onclick = (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
ChessInteractions.handlePieceClick(piece);
|
||||||
|
console.log("Clicked on:", piece);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
setupSquareEventHandlers() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
square.classList.remove("previous-start", "previous-end");
|
||||||
|
|
||||||
|
if (!square.classList.contains("legal")) {
|
||||||
|
square.onclick = () => ChessInteractions.clearHighlights();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightPreviousMove() {
|
||||||
|
if (GameState.previousMoveStart && GameState.previousMoveEnd) {
|
||||||
|
const [startRow, startCol] = ChessUtils.flipCoordinates(...GameState.previousMoveStart);
|
||||||
|
const [endRow, endCol] = ChessUtils.flipCoordinates(...GameState.previousMoveEnd);
|
||||||
|
|
||||||
|
const startIndex = startRow * 8 + startCol;
|
||||||
|
const endIndex = endRow * 8 + endCol;
|
||||||
|
|
||||||
|
document.getElementById(`square-${startIndex}`)?.classList.add("previous-start");
|
||||||
|
document.getElementById(`square-${endIndex}`)?.classList.add("previous-end");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setupDragAndDrop() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|
||||||
|
square.ondragover = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
square.ondrop = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
if (!GameState.selectedPiece) return;
|
||||||
|
|
||||||
|
const index = parseInt(square.id.split('-')[1], 10);
|
||||||
|
const targetRow = Math.floor(index / 8);
|
||||||
|
const targetCol = index % 8;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessBoard.js loaded");
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const ChessInteractions = {
|
||||||
|
async handlePieceClick(piece) {
|
||||||
|
this.clearHighlights();
|
||||||
|
GameState.setSelectedPiece(piece);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const moves = await ChessAPI.getLegalMoves(piece.id);
|
||||||
|
GameState.setLegalMoves(moves);
|
||||||
|
this.highlightSelected(piece.row, piece.col);
|
||||||
|
this.highlightLegalMoves(moves);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error in handlePieceClick:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightSelected(row, col) {
|
||||||
|
const [r, c] = ChessUtils.flipCoordinates(row, col);
|
||||||
|
const index = r * 8 + c;
|
||||||
|
document.getElementById(`square-${index}`).classList.add("selected");
|
||||||
|
},
|
||||||
|
|
||||||
|
highlightLegalMoves(moves) {
|
||||||
|
moves.forEach(move => {
|
||||||
|
const [r, c] = ChessUtils.flipCoordinates(move.row, move.col);
|
||||||
|
const index = r * 8 + c;
|
||||||
|
const square = document.getElementById(`square-${index}`);
|
||||||
|
square.classList.add("legal");
|
||||||
|
|
||||||
|
const img = square.querySelector("img");
|
||||||
|
if (img) img.onclick = null;
|
||||||
|
|
||||||
|
let overlay = document.createElement("div");
|
||||||
|
overlay.className = "legalOverlay";
|
||||||
|
square.appendChild(overlay);
|
||||||
|
|
||||||
|
square.onclick = () => ChessAPI.handleMove(r, c);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
clearHighlights() {
|
||||||
|
for (let i = 0; i < 64; i++) {
|
||||||
|
const square = document.getElementById(`square-${i}`);
|
||||||
|
|
||||||
|
const overlay = square.querySelector(".legalOverlay");
|
||||||
|
if (overlay) square.removeChild(overlay);
|
||||||
|
|
||||||
|
if (square.classList.contains("legal")) {
|
||||||
|
square.classList.remove("legal");
|
||||||
|
square.onclick = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
square.classList.remove("selected");
|
||||||
|
}
|
||||||
|
|
||||||
|
GameState.clearSelection();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessInteractions.js loaded");
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
const ChessModals = {
|
||||||
|
promptPromotion() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let color = 1;
|
||||||
|
if (GameState.currentPlayerIsWhite)
|
||||||
|
color = 0;
|
||||||
|
|
||||||
|
ChessUtils.updatePromotionModalImages(color);
|
||||||
|
|
||||||
|
document.getElementById("promotionModal").style.display = "block";
|
||||||
|
window.selectPromotion = (piece) => {
|
||||||
|
document.getElementById("promotionModal").style.display = "none";
|
||||||
|
resolve(piece);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
promptDifficulty() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
document.getElementById("difficultyModal").style.display = "block";
|
||||||
|
window.selectDifficulty = (difficulty) => {
|
||||||
|
document.getElementById("difficultyModal").style.display = "none";
|
||||||
|
resolve(difficulty);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessModals.js loaded");
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
const ChessSignalR = {
|
||||||
|
connection: null,
|
||||||
|
|
||||||
|
async setupConnection() {
|
||||||
|
this.connection = new signalR.HubConnectionBuilder()
|
||||||
|
.withUrl("/chessHub")
|
||||||
|
.configureLogging(signalR.LogLevel.Information)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
this.connection.onclose(err => {
|
||||||
|
console.error("❌ SignalR connection closed:", err?.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.connection.on("ReceiveMoveUpdate", async (gameId, moveDto, moveResultDto) => {
|
||||||
|
await this.handleMoveUpdate(gameId, moveDto, moveResultDto);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.connection.start();
|
||||||
|
console.log("✅ SignalR connected");
|
||||||
|
await this.connection.invoke("JoinWebsocketGroup", GameState.currentGameId);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("❌ SignalR failed to start or join:", err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async handleMoveUpdate(gameId, moveDto, moveResultDto) {
|
||||||
|
if (gameId !== GameState.currentGameId) return;
|
||||||
|
|
||||||
|
const gameState = await ChessAPI.getGameState(gameId);
|
||||||
|
GameState.setPreviousMove([moveDto.sourceRow, moveDto.sourceCol], [moveDto.targetRow, moveDto.targetCol]);
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
ChessAPI.alertGameStatusChange(moveResultDto);
|
||||||
|
},
|
||||||
|
|
||||||
|
async notifyMoveMade(moveDto, moveResult) {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.invoke("MoveMade", GameState.currentGameId, moveDto, moveResult);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async leaveGame() {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.invoke("LeaveWebsocketGroup", GameState.currentGameId);
|
||||||
|
await this.connection.stop();
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async stopConnection() {
|
||||||
|
if (this.connection) {
|
||||||
|
await this.connection.stop();
|
||||||
|
this.connection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessSignalR.js loaded");
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
const ChessUtils = {
|
||||||
|
flipCoordinates(row, col) {
|
||||||
|
if (!GameState.currentPlayerIsWhite) {
|
||||||
|
return [7 - row, 7 - col];
|
||||||
|
}
|
||||||
|
return [row, col];
|
||||||
|
},
|
||||||
|
|
||||||
|
getCookie(name) {
|
||||||
|
const value = document.cookie.split('; ')
|
||||||
|
.find(row => row.startsWith(name + '='));
|
||||||
|
return value ? value.split('=')[1] : null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setCookie(name, value, maxAge = 86400) {
|
||||||
|
document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
getPieceImageUrl(piece) {
|
||||||
|
const basePath = "/images/Chess Images/";
|
||||||
|
const color = piece.color === 0 ? "White" : "Black";
|
||||||
|
const typeMap = {
|
||||||
|
0: "Pawn",
|
||||||
|
1: "Rook",
|
||||||
|
2: "Knight",
|
||||||
|
3: "Bishop",
|
||||||
|
4: "Queen",
|
||||||
|
5: "King"
|
||||||
|
};
|
||||||
|
|
||||||
|
return basePath + color + typeMap[piece.type] + ".svg";
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePromotionModalImages(color) {
|
||||||
|
const pieceNames = ["Queen", "Rook", "Bishop", "Knight"];
|
||||||
|
const buttons = document.querySelectorAll("#promotionModal button img");
|
||||||
|
|
||||||
|
buttons.forEach((img, index) => {
|
||||||
|
img.src = `/images/Chess Images/${color === 0 ? "White" : "Black"}${pieceNames[index]}.svg`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("ChessUtils.js loaded");
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
const GameState = {
|
||||||
|
currentGameId: null,
|
||||||
|
currentPlayerId: null,
|
||||||
|
currentPlayerIsWhite: null,
|
||||||
|
selectedPiece: null,
|
||||||
|
legalMoves: [],
|
||||||
|
previousMoveStart: null,
|
||||||
|
previousMoveEnd: null,
|
||||||
|
|
||||||
|
reset() {
|
||||||
|
this.currentGameId = null;
|
||||||
|
this.currentPlayerId = null;
|
||||||
|
this.currentPlayerIsWhite = null;
|
||||||
|
this.selectedPiece = null;
|
||||||
|
this.legalMoves = [];
|
||||||
|
this.previousMoveStart = null;
|
||||||
|
this.previousMoveEnd = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
setGameInfo(gameId, playerId, isWhite) {
|
||||||
|
this.currentGameId = gameId;
|
||||||
|
this.currentPlayerId = playerId;
|
||||||
|
this.currentPlayerIsWhite = isWhite;
|
||||||
|
},
|
||||||
|
|
||||||
|
setSelectedPiece(piece) {
|
||||||
|
this.selectedPiece = piece;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearSelection() {
|
||||||
|
this.selectedPiece = null;
|
||||||
|
this.legalMoves = [];
|
||||||
|
},
|
||||||
|
|
||||||
|
setLegalMoves(moves) {
|
||||||
|
this.legalMoves = moves;
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreviousMove(startPos, endPos) {
|
||||||
|
this.previousMoveStart = startPos;
|
||||||
|
this.previousMoveEnd = endPos;
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPreviousMove() {
|
||||||
|
this.previousMoveStart = null;
|
||||||
|
this.previousMoveEnd = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("GameState.js loaded");
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
async function startNewGame() {
|
||||||
|
await ChessSignalR.stopConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const gameData = await ChessAPI.joinGame();
|
||||||
|
|
||||||
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
|
GameState.clearPreviousMove();
|
||||||
|
|
||||||
|
ChessUtils.setCookie('chessGameId', gameData.gameId);
|
||||||
|
ChessUtils.setCookie('chessPlayerId', gameData.id);
|
||||||
|
ChessUtils.setCookie('chessPlayerIsWhite', gameData.isWhite);
|
||||||
|
|
||||||
|
console.log("🆕 Game started:", gameData.gameId);
|
||||||
|
|
||||||
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start new game:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startCPUGame() {
|
||||||
|
await ChessSignalR.stopConnection();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const difficulty = await ChessModals.promptDifficulty();
|
||||||
|
const gameData = await ChessAPI.createCPUGame(difficulty);
|
||||||
|
|
||||||
|
GameState.setGameInfo(gameData.gameId, gameData.id, gameData.isWhite);
|
||||||
|
GameState.clearPreviousMove();
|
||||||
|
|
||||||
|
ChessUtils.setCookie('chessGameId', gameData.gameId);
|
||||||
|
ChessUtils.setCookie('chessPlayerId', gameData.id);
|
||||||
|
ChessUtils.setCookie('chessPlayerIsWhite', gameData.isWhite);
|
||||||
|
|
||||||
|
console.log("🆕 CPU Game started:", gameData.gameId);
|
||||||
|
|
||||||
|
await ChessSignalR.setupConnection();
|
||||||
|
|
||||||
|
const gameState = await ChessAPI.getGameState(gameData.gameId);
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start CPU game:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resumeSavedGame() {
|
||||||
|
const savedGameId = ChessUtils.getCookie("chessGameId");
|
||||||
|
const savedPlayerId = ChessUtils.getCookie("chessPlayerId");
|
||||||
|
const savedPlayerIsWhite = ChessUtils.getCookie("chessPlayerIsWhite");
|
||||||
|
|
||||||
|
if (savedGameId && savedPlayerId) {
|
||||||
|
try {
|
||||||
|
const gameState = await ChessAPI.getGameState(savedGameId);
|
||||||
|
|
||||||
|
console.log("🧠 Rejoining saved game...");
|
||||||
|
GameState.setGameInfo(savedGameId, savedPlayerId, savedPlayerIsWhite === "true");
|
||||||
|
|
||||||
|
await ChessSignalR.setupConnection();
|
||||||
|
ChessBoard.renderPieces(gameState.pieces);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("Saved game not found or expired.", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.startNewGame = startNewGame;
|
||||||
|
window.startCPUGame = startCPUGame;
|
||||||
|
|
||||||
|
window.addEventListener('load', resumeSavedGame);
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
ChessBoard.setupDragAndDrop();
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("chessMain.js loaded");
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
window.onload = function () {
|
||||||
|
const element = document.getElementById("WelcomeMessage");
|
||||||
|
element.style.display = "none"; // Trigger reflow
|
||||||
|
element.offsetHeight; // Force reflow
|
||||||
|
element.style.display = ""; // Restore the original display
|
||||||
|
typeText("#WelcomeMessage", "Today, October 2nd, marks the 5th anniversary of the day I was sealed for time and all eternity to my wonderful wife. She's the kindest, sweetest, funniest, most beautiful woman I've ever met. I love you Morgan 💖");
|
||||||
|
}
|
||||||
@@ -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! Since I have your attention, why don't I tell you 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?");
|
||||||
}
|
}
|
||||||
@@ -12,24 +12,28 @@
|
|||||||
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, punctuationDict[text.charAt(index)] ?? 50);
|
||||||
simulateTyping(element, text, 200);
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
clearInterval(intervalId);
|
|
||||||
simulateTyping(element, text);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulateTyping(element, text, speed = 50) {
|
function simulateTyping(element, text, speed = 50) {
|
||||||
intervalId = setInterval(() => {
|
intervalId = setInterval(() => {
|
||||||
if (index < text.length) {
|
if (index < text.length) {
|
||||||
currentText += text.charAt(index);
|
if (text.charAt(index) === '`')
|
||||||
|
currentText = currentText.slice(0, -1);
|
||||||
|
else
|
||||||
|
currentText += text.charAt(index);
|
||||||
changeIntervalTime(element, text);
|
changeIntervalTime(element, text);
|
||||||
index++;
|
index++;
|
||||||
element.textContent = currentText;
|
element.textContent = currentText;
|
||||||
@@ -41,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"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -64,4 +68,11 @@ function showClickedContents(option) {
|
|||||||
inline: "nearest"
|
inline: "nearest"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else if (option === 'blog') {
|
||||||
|
document.querySelector("#BlogBox").scrollIntoView({
|
||||||
|
behavior: "smooth",
|
||||||
|
block: "start",
|
||||||
|
inline: "nearest"
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||