diff --git a/.junie/plans/convert-to-blazor-server.md b/.junie/plans/convert-to-blazor-server.md new file mode 100644 index 0000000..34e61c7 --- /dev/null +++ b/.junie/plans/convert-to-blazor-server.md @@ -0,0 +1,151 @@ +--- +sessionId: session-260909-133404-1um1 +--- + +# Requirements + +### Overview & Goals +Convert the `Web` project from a standalone Blazor WebAssembly application back to an ASP.NET Core Blazor Server application with interactive server components. + +### Scope +#### In Scope +- Update `Web/Web.csproj` to use `Microsoft.NET.Sdk.Web` and remove WebAssembly client dependencies. +- Convert `Web/Program.cs` from `WebAssemblyHostBuilder` to ASP.NET Core `WebApplication.CreateBuilder` with Razor Components and interactive server render modes. +- Relocate configuration files (`appsettings.json`, `appsettings.Development.json`) from `Web/wwwroot/` to the project root `Web/`. +- Remove `Web/wwwroot/index.html` and restore `Web/Components/App.razor` as the HTML host root. +- Add `Web/Components/Routes.razor`, `Web/Components/Pages/Error.razor`, and `Web/Components/Layout/ReconnectModal.razor` (with corresponding CSS and JS). +- Update `Web/Components/_Imports.razor` with server rendering imports. + +#### Out of Scope +- Modifying resume content data files (`Overview.cs`, `PersonalInfo.cs`, `Skills.cs`, `WorkExperience.cs`). +- Modifying resume UI layouts or styles in `Home.razor`, `MainLayout.razor`, or `app.css`. + +### User Stories +- As a visitor, I want to load the resume application rendered quickly from the ASP.NET Core server with interactive server capabilities. +- As a developer, I want the project configured as a standard ASP.NET Core Blazor Server app so that it can leverage server-side capabilities and runtime services. + +### Functional Requirements +- The application must start using ASP.NET Core `WebApplication` hosting. +- Navigation to `/` must render the resume page (`Home.razor`) inside `MainLayout.razor`. +- Navigation to unknown routes must display the custom not-found page (`NotFound.razor`) via `UseStatusCodePagesWithReExecute("/not-found")` and the Blazor router. +- Disconnected or reconnecting Blazor Server circuits must display the interactive reconnection modal dialog. +- Server-side error routing must be handled via `/Error` rendering `Error.razor`. + +### Non-Functional Requirements +- Maintain compatibility with .NET 10 (`net10.0`). +- Ensure clean build with zero compile errors. + +# Technical Design + +### Current Implementation +The `Web` project is currently configured as a standalone Blazor WebAssembly client using `Microsoft.NET.Sdk.BlazorWebAssembly`, with `Program.cs` invoking `WebAssemblyHostBuilder.CreateDefault(args)`, static hosting through `Web/wwwroot/index.html`, and client configuration files in `Web/wwwroot/`. + +### Key Decisions +- **Hosting Model**: Use ASP.NET Core Blazor Server with interactive server components (`AddInteractiveServerComponents()` and `.AddInteractiveServerRenderMode()`). +- **Host Document**: Use `Web/Components/App.razor` as the HTML root document containing head elements, styles, scripts, reconnect modal, and ``. +- **Configuration Placement**: Move `appsettings.json` and `appsettings.Development.json` to the `Web/` project root, matching standard ASP.NET Core hosting conventions. + +### Proposed Changes +1. **`Web/Web.csproj`**: + - Change SDK to `Microsoft.NET.Sdk.Web`. + - Remove `` and ``. + - Set `true`. +2. **`Web/Program.cs`**: + - Build server host via `var builder = WebApplication.CreateBuilder(args);`. + - Register `builder.Services.AddRazorComponents().AddInteractiveServerComponents();`. + - Configure HTTP request pipeline: exception handler `/Error`, HSTS, status code re-execute `/not-found`, HTTPS redirection, antiforgery, static assets mapping (`MapStaticAssets()`), and `MapRazorComponents().AddInteractiveServerRenderMode()`. +3. **`Web/wwwroot/`**: + - Remove `index.html`. + - Move `appsettings.json` and `appsettings.Development.json` up to `Web/`. +4. **`Web/Components/`**: + - `App.razor`: HTML host markup with ``, ``, ``. + - `Routes.razor`: `` definition referencing `Program.Assembly` and `NotFoundPage="typeof(Pages.NotFound)"`. + - `Layout/ReconnectModal.razor` (+ `.razor.css`, `.razor.js`): Circuit reconnection overlay. + - `Pages/Error.razor`: Error display component. + - `_Imports.razor`: Add `@using static Microsoft.AspNetCore.Components.Web.RenderMode` and remove WebAssembly-only namespaces. + +### File Structure +``` +Web/ +├── appsettings.json +├── appsettings.Development.json +├── Program.cs +├── Web.csproj +├── Properties/ +│ └── launchSettings.json +├── Components/ +│ ├── _Imports.razor +│ ├── App.razor +│ ├── Routes.razor +│ ├── Layout/ +│ │ ├── MainLayout.razor +│ │ ├── MainLayout.razor.css +│ │ ├── ReconnectModal.razor +│ │ ├── ReconnectModal.razor.css +│ │ └── ReconnectModal.razor.js +│ └── Pages/ +│ ├── Error.razor +│ ├── Home.razor +│ ├── Home.razor.css +│ └── NotFound.razor +├── Data/ +│ ├── Overview.cs +│ ├── PersonalInfo.cs +│ ├── Skills.cs +│ └── WorkExperience.cs +└── wwwroot/ + ├── app.css + ├── favicon.png + └── lib/bootstrap/... +``` + +### Architecture Diagram +```mermaid +graph TD + Browser[Web Browser] -->|HTTP / WebSocket SignalR| ASPNET[ASP.NET Core Server] + ASPNET --> Pipeline[Middleware Pipeline] + Pipeline --> MapStatic[MapStaticAssets] + Pipeline --> RazorComponents[MapRazorComponents: App.razor] + RazorComponents --> Routes[Routes.razor / Router] + Routes --> MainLayout[MainLayout.razor] + MainLayout --> Home[Home.razor] + MainLayout --> NotFound[NotFound.razor] + Home --> Data[Data Layer: Overview, PersonalInfo, Skills, WorkExperience] +``` + +# Testing + +### Validation Approach +- Verify successful compilation with `dotnet build Web/Web.csproj`. +- Validate that no obsolete WebAssembly packages or unused `index.html` remain. +- Validate that all server components, router, reconnect modal, error handling, and static asset mappings compile and link properly. + +### Key Scenarios +- **Build Verification**: Ensure `dotnet build Web/Web.csproj` completes with 0 errors. +- **Root Page Loading**: Ensure root route `/` routes to `Home.razor` within `MainLayout.razor`. +- **404 Handling**: Ensure unknown routes re-execute to `/not-found` rendering `NotFound.razor`. +- **Error Page**: Ensure `/Error` is routable to `Error.razor`. + +### Edge Cases +- Disconnected SignalR circuit: Reconnection modal displays appropriate rejoining/paused/failed statuses. +- Static asset path resolution: Verify `_framework/blazor.web.js`, Bootstrap CSS, and `app.css` resolve with `@Assets`. + +# Delivery Steps + +### ✓ Step 1: Convert project SDK, configuration files, and ASP.NET Core server pipeline +The Web project is configured as an ASP.NET Core Web application with server hosting services and pipeline middleware. + +- Update `Web/Web.csproj` SDK to `Microsoft.NET.Sdk.Web`, remove `Microsoft.AspNetCore.Components.WebAssembly` package dependencies, and set `BlazorDisableThrowNavigationException`. +- Relocate `appsettings.json` and `appsettings.Development.json` from `Web/wwwroot/` to the project root directory `Web/`. +- Remove the standalone static `Web/wwwroot/index.html` host file. +- Rewrite `Web/Program.cs` to initialize `WebApplication.CreateBuilder(args)`, configure Razor components with interactive server support (`AddRazorComponents().AddInteractiveServerComponents()`), and set up HTTP middleware (antiforgery, static asset mapping, status code re-execution, exception handling, and `MapRazorComponents().AddInteractiveServerRenderMode()`). + +### ✓ Step 2: Restore Blazor Server host, routing, reconnect UX, and imports +The application renders HTML from the server with full routing, error handling, and circuit reconnection UI. + +- Update `Web/Components/App.razor` to serve as the HTML root document with ``, ``, ``, stylesheet assets, and `_framework/blazor.web.js`. +- Add `Web/Components/Routes.razor` defining the `` with `AppAssembly="typeof(Program).Assembly"` and `NotFoundPage="typeof(Pages.NotFound)"`. +- Add `Web/Components/Layout/ReconnectModal.razor` and its scoped CSS / JS assets (`ReconnectModal.razor.css`, `ReconnectModal.razor.js`) to handle Blazor Server circuit reconnection states. +- Add `Web/Components/Pages/Error.razor` to handle server-side request errors and show request diagnostic IDs. +- Update `Web/Components/_Imports.razor` to import static render modes and server component namespaces. +- Verify the solution builds without errors and serves the resume application over Blazor Server. \ No newline at end of file diff --git a/Web/Components/App.razor b/Web/Components/App.razor index af41dcc..788d606 100644 --- a/Web/Components/App.razor +++ b/Web/Components/App.razor @@ -1,12 +1,23 @@ - - - - - - - Not found - -

Sorry, there's nothing at this address.

-
-
-
+ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Web/Components/Layout/ReconnectModal.razor b/Web/Components/Layout/ReconnectModal.razor new file mode 100644 index 0000000..46b9813 --- /dev/null +++ b/Web/Components/Layout/ReconnectModal.razor @@ -0,0 +1,31 @@ + + + +
+ +

+ Rejoining the server... +

+

+ Rejoin failed... trying again in seconds. +

+

+ Failed to rejoin.
Please retry or reload the page. +

+ +

+ The session has been paused by the server. +

+

+ Failed to resume the session.
Please retry or reload the page. +

+ +
+
diff --git a/Web/Components/Layout/ReconnectModal.razor.css b/Web/Components/Layout/ReconnectModal.razor.css new file mode 100644 index 0000000..3ad3773 --- /dev/null +++ b/Web/Components/Layout/ReconnectModal.razor.css @@ -0,0 +1,157 @@ +.components-reconnect-first-attempt-visible, +.components-reconnect-repeated-attempt-visible, +.components-reconnect-failed-visible, +.components-pause-visible, +.components-resume-failed-visible, +.components-rejoining-animation { + display: none; +} + +#components-reconnect-modal.components-reconnect-show .components-reconnect-first-attempt-visible, +#components-reconnect-modal.components-reconnect-show .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-paused .components-pause-visible, +#components-reconnect-modal.components-reconnect-resume-failed .components-resume-failed-visible, +#components-reconnect-modal.components-reconnect-retrying, +#components-reconnect-modal.components-reconnect-retrying .components-reconnect-repeated-attempt-visible, +#components-reconnect-modal.components-reconnect-retrying .components-rejoining-animation, +#components-reconnect-modal.components-reconnect-failed, +#components-reconnect-modal.components-reconnect-failed .components-reconnect-failed-visible { + display: block; +} + + +#components-reconnect-modal { + background-color: white; + width: 20rem; + margin: 20vh auto; + padding: 2rem; + border: 0; + border-radius: 0.5rem; + box-shadow: 0 3px 6px 2px rgba(0, 0, 0, 0.3); + opacity: 0; + transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete; + animation: components-reconnect-modal-fadeOutOpacity 0.5s both; + &[open] + +{ + animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s; + animation-fill-mode: both; +} + +} + +#components-reconnect-modal::backdrop { + background-color: rgba(0, 0, 0, 0.4); + animation: components-reconnect-modal-fadeInOpacity 0.5s ease-in-out; + opacity: 1; +} + +@keyframes components-reconnect-modal-slideUp { + 0% { + transform: translateY(30px) scale(0.95); + } + + 100% { + transform: translateY(0); + } +} + +@keyframes components-reconnect-modal-fadeInOpacity { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes components-reconnect-modal-fadeOutOpacity { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +.components-reconnect-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; +} + +#components-reconnect-modal p { + margin: 0; + text-align: center; +} + +#components-reconnect-modal button { + border: 0; + background-color: #6b9ed2; + color: white; + padding: 4px 24px; + border-radius: 4px; +} + + #components-reconnect-modal button:hover { + background-color: #3b6ea2; + } + + #components-reconnect-modal button:active { + background-color: #6b9ed2; + } + +.components-rejoining-animation { + position: relative; + width: 80px; + height: 80px; +} + + .components-rejoining-animation div { + position: absolute; + border: 3px solid #0087ff; + opacity: 1; + border-radius: 50%; + animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite; + } + + .components-rejoining-animation div:nth-child(2) { + animation-delay: -0.5s; + } + +@keyframes components-rejoining-animation { + 0% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 4.9% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 0; + } + + 5% { + top: 40px; + left: 40px; + width: 0; + height: 0; + opacity: 1; + } + + 100% { + top: 0px; + left: 0px; + width: 80px; + height: 80px; + opacity: 0; + } +} diff --git a/Web/Components/Layout/ReconnectModal.razor.js b/Web/Components/Layout/ReconnectModal.razor.js new file mode 100644 index 0000000..a44de78 --- /dev/null +++ b/Web/Components/Layout/ReconnectModal.razor.js @@ -0,0 +1,63 @@ +// Set up event handlers +const reconnectModal = document.getElementById("components-reconnect-modal"); +reconnectModal.addEventListener("components-reconnect-state-changed", handleReconnectStateChanged); + +const retryButton = document.getElementById("components-reconnect-button"); +retryButton.addEventListener("click", retry); + +const resumeButton = document.getElementById("components-resume-button"); +resumeButton.addEventListener("click", resume); + +function handleReconnectStateChanged(event) { + if (event.detail.state === "show") { + reconnectModal.showModal(); + } else if (event.detail.state === "hide") { + reconnectModal.close(); + } else if (event.detail.state === "failed") { + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } else if (event.detail.state === "rejected") { + location.reload(); + } +} + +async function retry() { + document.removeEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + + try { + // Reconnect will asynchronously return: + // - true to mean success + // - false to mean we reached the server, but it rejected the connection (e.g., unknown circuit ID) + // - exception to mean we didn't reach the server (this can be sync or async) + const successful = await Blazor.reconnect(); + if (!successful) { + // We have been able to reach the server, but the circuit is no longer available. + // We'll reload the page so the user can continue using the app as quickly as possible. + const resumeSuccessful = await Blazor.resumeCircuit(); + if (!resumeSuccessful) { + location.reload(); + } else { + reconnectModal.close(); + } + } + } catch (err) { + // We got an exception, server is currently unavailable + document.addEventListener("visibilitychange", retryWhenDocumentBecomesVisible); + } +} + +async function resume() { + try { + const successful = await Blazor.resumeCircuit(); + if (!successful) { + location.reload(); + } + } catch { + reconnectModal.classList.replace("components-reconnect-paused", "components-reconnect-resume-failed"); + } +} + +async function retryWhenDocumentBecomesVisible() { + if (document.visibilityState === "visible") { + await retry(); + } +} diff --git a/Web/Components/Pages/Error.razor b/Web/Components/Pages/Error.razor new file mode 100644 index 0000000..7474392 --- /dev/null +++ b/Web/Components/Pages/Error.razor @@ -0,0 +1,36 @@ +@page "/Error" +@using System.Diagnostics + +Error + +

Error.

+

An error occurred while processing your request.

+ +@if (ShowRequestId) +{ +

+ Request ID: @RequestId +

+} + +

Development Mode

+

+ Swapping to Development environment will display more detailed information about the error that occurred. +

+

+ The Development environment shouldn't be enabled for deployed applications. + It can result in displaying sensitive information from exceptions to end users. + For local debugging, enable the Development environment by setting the ASPNETCORE_ENVIRONMENT environment variable to Development + and restarting the app. +

+ +@code{ + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private string? RequestId { get; set; } + private bool ShowRequestId => !string.IsNullOrEmpty(RequestId); + + protected override void OnInitialized() => + RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier; +} diff --git a/Web/Components/Routes.razor b/Web/Components/Routes.razor new file mode 100644 index 0000000..ebc312b --- /dev/null +++ b/Web/Components/Routes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/Web/Components/_Imports.razor b/Web/Components/_Imports.razor index f55b99f..443f6ba 100644 --- a/Web/Components/_Imports.razor +++ b/Web/Components/_Imports.razor @@ -1,10 +1,10 @@ -@using System.Net.Http +@using System.Net.Http @using System.Net.Http.Json @using Microsoft.AspNetCore.Components.Forms @using Microsoft.AspNetCore.Components.Routing @using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components.Web.Virtualization -@using Microsoft.AspNetCore.Components.WebAssembly.Http @using Microsoft.JSInterop @using Web @using Web.Components diff --git a/Web/Data/WorkExperience.cs b/Web/Data/WorkExperience.cs index 710f7c2..a564334 100644 --- a/Web/Data/WorkExperience.cs +++ b/Web/Data/WorkExperience.cs @@ -91,7 +91,7 @@ public static class WorkExperience new Point { Description = - "Came to the conclusion that I wanted to focus my career on .NET, and left the company to pursue Blazor research and C# opportunities. Unfortunately, Blazor development isn't popular.", + "Came to the conclusion that I wanted to focus my career on .NET, and left the company to pursue Blazor research and C# opportunities. Unfortunately, Blazor development isn't popular, and such specialization makes less sense in the AI era.", IsVisible = true } ] diff --git a/Web/Program.cs b/Web/Program.cs index a2976c0..0150637 100644 --- a/Web/Program.cs +++ b/Web/Program.cs @@ -1,11 +1,28 @@ -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; using Web.Components; -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); +var builder = WebApplication.CreateBuilder(args); -builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) }); +// Add services to the container. +builder.Services.AddRazorComponents() + .AddInteractiveServerComponents(); -await builder.Build().RunAsync(); +var app = builder.Build(); + +// Configure the HTTP request pipeline. +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler("/Error", createScopeForErrors: true); + // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. + app.UseHsts(); +} + +app.UseStatusCodePagesWithReExecute("/not-found"); +app.UseHttpsRedirection(); + +app.UseAntiforgery(); + +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode(); + +app.Run(); diff --git a/Web/Properties/launchSettings.json b/Web/Properties/launchSettings.json index f7ea3de..82e04d3 100644 --- a/Web/Properties/launchSettings.json +++ b/Web/Properties/launchSettings.json @@ -1,25 +1,23 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", - "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "http://localhost:5202", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": true, - "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}", - "applicationUrl": "https://localhost:7263;http://localhost:5202", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": true, + "applicationUrl": "https://localhost:7263;http://localhost:5202", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" } } } +} diff --git a/Web/Web.csproj b/Web/Web.csproj index 97886a9..5043083 100644 --- a/Web/Web.csproj +++ b/Web/Web.csproj @@ -1,15 +1,11 @@ - + net10.0 enable enable Web + true - - - - - diff --git a/Web/wwwroot/appsettings.Development.json b/Web/appsettings.Development.json similarity index 100% rename from Web/wwwroot/appsettings.Development.json rename to Web/appsettings.Development.json diff --git a/Web/wwwroot/appsettings.json b/Web/appsettings.json similarity index 100% rename from Web/wwwroot/appsettings.json rename to Web/appsettings.json diff --git a/Web/wwwroot/index.html b/Web/wwwroot/index.html deleted file mode 100644 index 2d3f14a..0000000 --- a/Web/wwwroot/index.html +++ /dev/null @@ -1,33 +0,0 @@ - - - - - - - Jonathan McCaffrey - Resume - - - - - - - - - -
- - - - -
-
- -
- An unhandled error has occurred. - Reload - 🗙 -
- - - -