From 35c60c4ea22e93109d6b767cad398b48ae977b1d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 25 Feb 2026 12:18:28 +0100 Subject: [PATCH] Replace frontend with Blazor UX implementation --- FAQ.md | 10 +- FRONTEND_PROGRESS.md | 35 + README.md | 24 +- RpgRoller.Tests/Api/FrontendHostTests.cs | 24 + RpgRoller/Components/App.razor | 1 + RpgRoller/Components/Pages/Home.razor | 474 ++++++++- RpgRoller/Components/Pages/Home.razor.cs | 1157 ++++++++++++++++++++++ RpgRoller/Components/Routes.razor | 2 + RpgRoller/Program.cs | 1 + RpgRoller/wwwroot/js/rpgroller-api.js | 137 ++- RpgRoller/wwwroot/styles.css | 509 +++++++++- 11 files changed, 2324 insertions(+), 50 deletions(-) create mode 100644 FRONTEND_PROGRESS.md create mode 100644 RpgRoller.Tests/Api/FrontendHostTests.cs create mode 100644 RpgRoller/Components/Pages/Home.razor.cs diff --git a/FAQ.md b/FAQ.md index 7ff7d3b..1fc405c 100644 --- a/FAQ.md +++ b/FAQ.md @@ -13,7 +13,15 @@ This keeps the first commit small while preserving CI discipline. Additional too ## Is frontend JavaScript handwritten? -No. Frontend source code lives in `RpgRoller/frontend/*.ts` and is compiled to `RpgRoller/wwwroot/*.js` for browser delivery. +The runtime UI is now built with Blazor components (`RpgRoller/Components/*`). + +There is still small handwritten JavaScript in `RpgRoller/wwwroot/js/rpgroller-api.js` for: + +- browser `fetch` calls with cookie auth +- SSE connection/reconnect handling +- per-tab session storage helpers used by the Blazor UI + +The TypeScript frontend folders remain in the repo for tooling and generated API client contract checks used by CI. ## Where is backend state stored locally? diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md new file mode 100644 index 0000000..687c919 --- /dev/null +++ b/FRONTEND_PROGRESS.md @@ -0,0 +1,35 @@ +# Frontend Rebuild Progress (Blazor) + +Tracking against `UX.md` tasks and decisions. + +## Status Snapshot + +- Branch: `feature/blazor-frontend-rebuild-ux` +- Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`) + +## UX Checklist + +| UX area | Status | Notes | +|---|---|---| +| 9.1 App load + session restore | Implemented | Health check on load, rulesets/session load, unauthorized session reset, API unhealthy retry banner. | +| 9.2 Authentication view | Implemented | Register/login cards, required validation, register password length check, server-error display. | +| 9.3 Shared authenticated header | Implemented | User chip, campaign/active context, connection state, screen switch, refresh, logout. | +| 9.4 Play screen character column | Implemented | Character icon tabs, sheet, modal edit/create flows, activate action, skill list, roll controls, last roll card. | +| 9.5 Play screen log column | Implemented | Chronological feed, private/public badges, private perspective styles (roller vs GM), local time + ISO tooltip. | +| 9.6 Campaign management screen | Implemented | Campaign selector/summary, create form, details card, character management actions with modal edit pattern. | +| 9.7 Tablet/mobile bottom bar | Implemented | `Character` / `Log` panel switch in play screen and per-tab session persistence. | +| 10 Validation and error UX | Partially implemented | Required-field and common API errors are mapped; message/code-specific mapping is limited by current API exposing only text messages. | +| 11 Empty/loading/disabled states | Implemented | Empty states, skeleton placeholders, mutation button disabling. | +| 12 Real-time and sync rules | Implemented | Campaign-scoped SSE subscribe/unsubscribe, reconnect with exponential backoff, manual refresh fallback. | +| 13 Accessibility requirements | Partially implemented | Keyboard-friendly controls, labels, focus styling, `aria-live` announcements; screen-reader validation for all flows still needs dedicated accessibility QA. | +| 14 Content and copy guidance | Implemented | Direct action labels and corrective error copy used throughout. | +| 15 Visual direction | Implemented | Tabletop utility styling, tokenized colors, responsive layout, private/public visual differentiation. | +| 17 Next iteration targets: wireframes | Not yet implemented | No separate low-fidelity wireframe artifact added in repo. | +| 17 Next iteration targets: component contracts doc | Not yet implemented | Component contract document not yet extracted from implementation. | +| 17 Next iteration targets: visual token doc | Not yet implemented | Tokens are implemented in CSS but not yet documented in a dedicated spec file. | + +## Follow-up Candidates + +1. Add explicit machine-readable API error codes to HTTP responses for richer field-level mapping. +2. Add automated accessibility checks (focus order, contrast, and screen-reader behavior assertions). +3. Document component contracts and visual token references as separate markdown artifacts. diff --git a/README.md b/README.md index 85ac492..3061bfa 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ Fresh full-stack starter scaffold: -- `RpgRoller/`: ASP.NET Core backend + static frontend output (`wwwroot`) +- `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`) - `RpgRoller/frontend/`: TypeScript frontend source - `RpgRoller.Tests/`: xUnit integration-heavy test project - `RpgRoller.sln`: solution used by local CI script - `UX.md`: frontend UX and interaction design specification (pre-implementation baseline) +- `FRONTEND_PROGRESS.md`: implementation tracking (`Implemented` / `Partially implemented` / `Not yet implemented`) Test layout: @@ -25,10 +26,11 @@ Backend: Frontend: -- `RpgRoller/frontend/app.ts`: orchestration entrypoint -- `RpgRoller/frontend/app/`: split modules (`dom`, `state`, `loaders`, `render`, `events`, `actions`) -- `RpgRoller/frontend/generated/`: generated API client source -- `RpgRoller/wwwroot/`: compiled browser assets +- `RpgRoller/Components/`: Blazor root app, routes, layout and page components +- `RpgRoller/Components/Pages/Home.razor(.cs)`: main UX implementation for auth/play/management screens +- `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor +- `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens +- `RpgRoller/frontend/generated/`: generated TypeScript API client source retained for contract/tooling parity Backend state persistence: @@ -92,10 +94,14 @@ To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`. ## Implemented Frontend Scope -- TypeScript-driven UI for: +- Blazor-driven UI for: - registration, login, logout + - play screen and campaign management screen switch - campaign creation and selection - - character creation and activation - - skill creation and editing + - character create/edit/activate via modal forms + - skill create/edit via modal forms - public/private rolling and campaign log viewing -- SSE-backed live refresh for selected campaign state/log +- responsive play UX: + - desktop two-column (character + log) + - tablet/mobile panel switching with bottom tab bar (`Character` / `Log`) +- SSE-backed live refresh with reconnect status + manual refresh fallback diff --git a/RpgRoller.Tests/Api/FrontendHostTests.cs b/RpgRoller.Tests/Api/FrontendHostTests.cs new file mode 100644 index 0000000..d7c745a --- /dev/null +++ b/RpgRoller.Tests/Api/FrontendHostTests.cs @@ -0,0 +1,24 @@ +using Microsoft.AspNetCore.Mvc.Testing; + +namespace RpgRoller.Tests; + +public sealed class FrontendHostTests : ApiTestBase +{ + public FrontendHostTests(WebApplicationFactory factory) + : base(factory) + { + } + + [Fact] + public async Task RootPath_ServesBlazorFrontendShell() + { + using var factory = CreateFactory(1); + using var client = factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + var response = await client.GetAsync("/"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var html = await response.Content.ReadAsStringAsync(); + Assert.Contains("_framework/blazor.web.js", html); + Assert.Contains("Connecting...", html); + } +} diff --git a/RpgRoller/Components/App.razor b/RpgRoller/Components/App.razor index 781007e..68095a9 100644 --- a/RpgRoller/Components/App.razor +++ b/RpgRoller/Components/App.razor @@ -1,4 +1,5 @@ @using Microsoft.AspNetCore.Components.Web +@attribute [ExcludeFromCodeCoverage] diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index c892341..1cba251 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -1,7 +1,471 @@ @page "/" -@attribute [ExcludeFromCodeCoverage] +@implements IAsyncDisposable -
-

RpgRoller

-

Frontend migration in progress: Blazor shell is active.

-
+
+

@LiveAnnouncement

+ + @if (!IsInitialized) + { +
+

RpgRoller

+

Connecting...

+
+ } + else + { + @if (HasHealthIssue) + { + + } + + @if (User is null) + { +
+

RpgRoller

+

Register or log in to join a campaign session.

+ @if (!string.IsNullOrWhiteSpace(StatusMessage)) + { +

@StatusMessage

+ } +
+
+

Register

+ @if (!string.IsNullOrWhiteSpace(RegisterFormError)) + { +

@RegisterFormError

+ } +
+ + + @if (RegisterErrors.TryGetValue("username", out var registerUsernameError)) + { +

@registerUsernameError

+ } + + + @if (RegisterErrors.TryGetValue("displayName", out var registerDisplayNameError)) + { +

@registerDisplayNameError

+ } + + + @if (RegisterErrors.TryGetValue("password", out var registerPasswordError)) + { +

@registerPasswordError

+ } + +
+
+ +
+

Login

+ @if (!string.IsNullOrWhiteSpace(LoginFormError)) + { +

@LoginFormError

+ } +
+ + + @if (LoginErrors.TryGetValue("username", out var loginUsernameError)) + { +

@loginUsernameError

+ } + + + @if (LoginErrors.TryGetValue("password", out var loginPasswordError)) + { +

@loginPasswordError

+ } + +
+
+
+
+ } + else + { +
+
+
+

RpgRoller

+

Tabletop utility cockpit

+
+
+

@User.DisplayName (@User.Username)

+

Campaign: @(SelectedCampaignName ?? "No campaign selected")

+

Active: @(ActiveCharacterName ?? "None selected")

+
+
+

@ConnectionStateLabel

+
+ + +
+
+ + +
+
+
+ + @if (!string.IsNullOrWhiteSpace(StatusMessage)) + { +

@StatusMessage

+ } + + @if (CurrentScreen == "play") + { +
+
+

Character Context

+ @if (IsCampaignDataLoading) + { +
+ } + else if (SelectedCampaign is null) + { +

No campaign selected. Choose one in Campaign Management.

+ } + else if (SelectedCampaign.Characters.Count == 0) + { +

No characters in this campaign yet.

+ } + else + { +
+ @foreach (var character in SelectedCampaign.Characters) + { + var isSelectedCharacter = SelectedCharacterId == character.Id; + + } +
+ @if (SelectedCharacter is not null) + { +
+

@SelectedCharacter.Name

+

Owner: @OwnerLabel(SelectedCharacter.OwnerUserId)

+

Campaign: @SelectedCampaign.Name

+ @if (SelectedCharacter.Id == ActiveCharacterId) + { + Active + } +
+ + +
+
+
+
+

Skills

+
+ + +
+
+ @if (SelectedCharacterSkills.Count == 0) + { +

No skills for this character yet.

+ } + else + { +
+ @foreach (var skill in SelectedCharacterSkills) + { + var isSelectedSkill = SelectedSkillId == skill.Id; + + } +
+ } +
+ + + +
+
+ } + } +
+

Last Roll

+ @if (LastRoll is null) + { +

No roll yet.

+ } + else + { +

@LastRoll.Result

+

@LastRoll.Breakdown

+

@LastRoll.Visibility

+ } +
+
+ + +
+ + } + else + { +
+
+

Campaign Selector

+ @if (Campaigns.Count == 0) + { +

No campaigns yet.

+ } + else + { + + + } +

Current campaign in this tab: @(SelectedCampaignName ?? "None selected")

+
+
+

Create Campaign

+ @if (!string.IsNullOrWhiteSpace(CampaignFormError)) + { +

@CampaignFormError

+ } +
+ + + @if (CampaignErrors.TryGetValue("name", out var campaignNameError)) + { +

@campaignNameError

+ } + + + @if (CampaignErrors.TryGetValue("rulesetId", out var campaignRulesetError)) + { +

@campaignRulesetError

+ } + +
+
+
+

Campaign Details

+ @if (SelectedCampaign is null) + { +

No campaign selected.

+ } + else + { +

Name: @SelectedCampaign.Name

+

Ruleset: @SelectedCampaign.RulesetId

+

GM: @SelectedCampaign.Gm.DisplayName (@SelectedCampaign.Gm.Username)

+

Characters visible: @SelectedCampaign.Characters.Count

+ } +
+
+
+

Character Management

+ +
+ @if (SelectedCampaign is null) + { +

Select a campaign first.

+ } + else if (SelectedCampaign.Characters.Count == 0) + { +

No characters in this campaign yet.

+ } + else + { +
    + @foreach (var character in SelectedCampaign.Characters) + { +
  • +
    @character.Name

    Owner: @OwnerLabel(character.OwnerUserId)

    +
    + + +
    +
  • + } +
+ } +
+
+ } +
+ } + } +
+ +@if (ShowCreateCharacterModal) +{ + +} + +@if (ShowEditCharacterModal) +{ + +} + +@if (ShowCreateSkillModal) +{ + +} + +@if (ShowEditSkillModal) +{ + +} diff --git a/RpgRoller/Components/Pages/Home.razor.cs b/RpgRoller/Components/Pages/Home.razor.cs new file mode 100644 index 0000000..f97add5 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -0,0 +1,1157 @@ +using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +[ExcludeFromCodeCoverage] +public partial class Home +{ + private const string ScreenSessionKey = "screen"; + private const string CampaignSessionKey = "campaign"; + private const string MobilePanelSessionKey = "play-panel"; + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + private RegisterFormModel RegisterForm { get; } = new(); + private LoginFormModel LoginForm { get; } = new(); + private CampaignFormModel CampaignForm { get; } = new(); + private CharacterFormModel CharacterForm { get; } = new(); + private CharacterFormModel EditCharacterForm { get; } = new(); + private SkillFormModel SkillForm { get; } = new(); + private SkillFormModel EditSkillForm { get; } = new(); + + private Dictionary RegisterErrors { get; } = []; + private Dictionary LoginErrors { get; } = []; + private Dictionary CampaignErrors { get; } = []; + private Dictionary CharacterErrors { get; } = []; + private Dictionary EditCharacterErrors { get; } = []; + private Dictionary SkillErrors { get; } = []; + private Dictionary EditSkillErrors { get; } = []; + + private string? RegisterFormError { get; set; } + private string? LoginFormError { get; set; } + private string? CampaignFormError { get; set; } + private string? CharacterFormError { get; set; } + private string? EditCharacterFormError { get; set; } + private string? SkillFormError { get; set; } + private string? EditSkillFormError { get; set; } + + private UserSummary? User { get; set; } + private Guid? ActiveCharacterId { get; set; } + private Guid? SelectedCampaignId { get; set; } + private CampaignDetails? SelectedCampaign { get; set; } + private List Campaigns { get; set; } = []; + private List CampaignLog { get; set; } = []; + private List Rulesets { get; set; } = []; + private Guid? SelectedCharacterId { get; set; } + private Guid? SelectedSkillId { get; set; } + private RollResult? LastRoll { get; set; } + private string RollVisibility { get; set; } = "public"; + + private bool IsInitialized { get; set; } + private bool IsMutating { get; set; } + private bool IsCampaignDataLoading { get; set; } + private bool HasHealthIssue { get; set; } + private string HealthIssueMessage { get; set; } = "Retry to restore the API connection."; + private string? StatusMessage { get; set; } + private bool StatusIsError { get; set; } + private string CurrentScreen { get; set; } = "play"; + private string MobilePanel { get; set; } = "character"; + private string ConnectionState { get; set; } = "offline"; + private string LiveAnnouncement { get; set; } = string.Empty; + + private bool ShowCreateCharacterModal { get; set; } + private bool ShowEditCharacterModal { get; set; } + private bool ShowCreateSkillModal { get; set; } + private bool ShowEditSkillModal { get; set; } + private Guid? EditingCharacterId { get; set; } + private Guid? EditingSkillId { get; set; } + private bool StateRefreshInProgress { get; set; } + private DotNetObjectReference? DotNetRef { get; set; } + + [Inject] + private IJSRuntime JS { get; set; } = default!; + + private string? SelectedCampaignName => SelectedCampaign?.Name; + private CharacterSummary? SelectedCharacter => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId); + private SkillSummary? SelectedSkill => SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == SelectedSkillId); + private string? ActiveCharacterName => SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == ActiveCharacterId)?.Name; + private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; + + private List SelectedCharacterSkills => + SelectedCampaign is null || !SelectedCharacterId.HasValue + ? [] + : SelectedCampaign.Skills.Where(skill => skill.CharacterId == SelectedCharacterId.Value) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private string ConnectionStateLabel => ConnectionState switch + { + "connected" => "Connected", + "reconnecting" => "Reconnecting", + _ => "Offline fallback" + }; + + private string ConnectionStateCssClass => ConnectionState switch + { + "connected" => "ok", + "reconnecting" => "warn", + _ => "offline" + }; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender) + { + return; + } + + await InitializeAsync(); + await InvokeAsync(StateHasChanged); + } + + private async Task InitializeAsync() + { + var storedScreen = await JS.InvokeAsync("rpgRollerApi.getSessionValue", ScreenSessionKey); + if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase)) + { + CurrentScreen = "management"; + } + + var storedPanel = await JS.InvokeAsync("rpgRollerApi.getSessionValue", MobilePanelSessionKey); + if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase)) + { + MobilePanel = "log"; + } + + Guid? preferredCampaignId = null; + var storedCampaignId = await JS.InvokeAsync("rpgRollerApi.getSessionValue", CampaignSessionKey); + if (Guid.TryParse(storedCampaignId, out var parsedCampaignId)) + { + preferredCampaignId = parsedCampaignId; + } + + await CheckHealthAsync(); + await LoadRulesetsAsync(); + await ReloadAuthenticatedSessionAsync(preferredCampaignId); + IsInitialized = true; + } + + private async Task RetryAfterHealthIssueAsync() + { + await CheckHealthAsync(); + if (!HasHealthIssue && User is not null) + { + await ReloadAuthenticatedSessionAsync(SelectedCampaignId); + } + } + + private async Task CheckHealthAsync() + { + try + { + var health = await RequestAsync("GET", "/api/health"); + if (!string.Equals(health.Status, "ok", StringComparison.OrdinalIgnoreCase)) + { + HasHealthIssue = true; + HealthIssueMessage = "Health endpoint returned an unhealthy response."; + return; + } + + HasHealthIssue = false; + HealthIssueMessage = string.Empty; + } + catch (ApiRequestException) + { + HasHealthIssue = true; + HealthIssueMessage = "Unable to reach API. Retry to continue."; + } + } + + private async Task LoadRulesetsAsync() + { + try + { + Rulesets = (await RequestAsync>("GET", "/api/rulesets")).ToList(); + if (Rulesets.Count > 0 && string.IsNullOrWhiteSpace(CampaignForm.RulesetId)) + { + CampaignForm.RulesetId = Rulesets[0].Id; + } + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + } + + private async Task ReloadAuthenticatedSessionAsync(Guid? preferredCampaignId) + { + var me = await TryGetMeAsync(); + if (me is null) + { + ClearAuthenticatedState(); + await StopStateEventsAsync(); + return; + } + + User = me.User; + ActiveCharacterId = me.ActiveCharacterId; + + await ReloadCampaignsAsync(preferredCampaignId ?? me.CurrentCampaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + } + + private async Task TryGetMeAsync() + { + try + { + return await RequestAsync("GET", "/api/me"); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + return null; + } + } + + private async Task ReloadCampaignsAsync(Guid? preferredCampaignId) + { + var campaigns = await RequestAsync>("GET", "/api/campaigns"); + Campaigns = campaigns.OrderBy(c => c.Name, StringComparer.OrdinalIgnoreCase).ToList(); + + if (Campaigns.Count == 0) + { + SelectedCampaignId = null; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, null); + return; + } + + var campaignIds = Campaigns.Select(c => c.Id).ToHashSet(); + if (preferredCampaignId.HasValue && campaignIds.Contains(preferredCampaignId.Value)) + { + SelectedCampaignId = preferredCampaignId.Value; + } + else if (!SelectedCampaignId.HasValue || !campaignIds.Contains(SelectedCampaignId.Value)) + { + SelectedCampaignId = Campaigns[0].Id; + } + + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, SelectedCampaignId?.ToString()); + } + + private async Task RefreshCampaignScopeAsync() + { + if (!SelectedCampaignId.HasValue) + { + SelectedCampaign = null; + CampaignLog = []; + SelectedCharacterId = null; + SelectedSkillId = null; + ConnectionState = "offline"; + return; + } + + IsCampaignDataLoading = true; + try + { + SelectedCampaign = await RequestAsync("GET", $"/api/campaigns/{SelectedCampaignId.Value}"); + CampaignLog = (await RequestAsync>("GET", $"/api/campaigns/{SelectedCampaignId.Value}/log")).ToList(); + SyncSelectedCharacter(); + SyncSelectedSkill(); + } + catch (ApiRequestException ex) when (ex.StatusCode == 401) + { + ClearAuthenticatedState(); + await StopStateEventsAsync(); + SetStatus("Session expired. Please log in again.", true); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsCampaignDataLoading = false; + } + } + + private async Task ManualRefreshAsync() + { + if (IsMutating) + { + return; + } + + IsMutating = true; + try + { + await CheckHealthAsync(); + await ReloadAuthenticatedSessionAsync(SelectedCampaignId); + SetStatus("Campaign data refreshed.", false); + } + finally + { + IsMutating = false; + } + } + + private async Task RegisterAsync() + { + RegisterErrors.Clear(); + RegisterFormError = null; + + if (string.IsNullOrWhiteSpace(RegisterForm.Username)) + { + RegisterErrors["username"] = "Username is required."; + } + + if (string.IsNullOrWhiteSpace(RegisterForm.DisplayName)) + { + RegisterErrors["displayName"] = "Display name is required."; + } + + if (string.IsNullOrWhiteSpace(RegisterForm.Password) || RegisterForm.Password.Length < 8) + { + RegisterErrors["password"] = "Password must be at least 8 characters."; + } + + if (RegisterErrors.Count > 0) + { + RegisterFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync("POST", "/api/auth/register", new RegisterRequest(RegisterForm.Username.Trim(), RegisterForm.Password, RegisterForm.DisplayName.Trim())); + RegisterForm.Password = string.Empty; + SetStatus("Registration successful. You can log in now.", false); + } + catch (ApiRequestException ex) + { + if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) + { + RegisterErrors["username"] = "Username is already taken. Choose another one."; + } + else + { + RegisterFormError = ex.Message; + } + } + finally + { + IsMutating = false; + } + } + + private async Task LoginAsync() + { + LoginErrors.Clear(); + LoginFormError = null; + + if (string.IsNullOrWhiteSpace(LoginForm.Username)) + { + LoginErrors["username"] = "Username is required."; + } + + if (string.IsNullOrWhiteSpace(LoginForm.Password)) + { + LoginErrors["password"] = "Password is required."; + } + + if (LoginErrors.Count > 0) + { + LoginFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync("POST", "/api/auth/login", new LoginRequest(LoginForm.Username.Trim(), LoginForm.Password)); + LoginForm.Password = string.Empty; + await ReloadAuthenticatedSessionAsync(null); + SetStatus("Logged in.", false); + } + catch (ApiRequestException ex) + { + LoginFormError = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task LogoutAsync() + { + if (IsMutating) + { + return; + } + + IsMutating = true; + try + { + await RequestWithoutPayloadAsync("POST", "/api/auth/logout"); + } + catch (ApiRequestException) + { + } + finally + { + IsMutating = false; + } + + ClearAuthenticatedState(); + await StopStateEventsAsync(); + SetStatus("Logged out.", false); + } + + private async Task SwitchScreenAsync(string screen) + { + CurrentScreen = string.Equals(screen, "management", StringComparison.OrdinalIgnoreCase) ? "management" : "play"; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", ScreenSessionKey, CurrentScreen); + } + + private Task SwitchToPlayAsync() + { + return SwitchScreenAsync("play"); + } + + private Task SwitchToManagementAsync() + { + return SwitchScreenAsync("management"); + } + + private async Task SetMobilePanelAsync(string panel) + { + MobilePanel = string.Equals(panel, "log", StringComparison.OrdinalIgnoreCase) ? "log" : "character"; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", MobilePanelSessionKey, MobilePanel); + } + + private Task SetMobilePanelCharacterAsync() + { + return SetMobilePanelAsync("character"); + } + + private Task SetMobilePanelLogAsync() + { + return SetMobilePanelAsync("log"); + } + + private async Task OnCampaignSelectionChangedAsync(ChangeEventArgs args) + { + if (args.Value is null || !Guid.TryParse(args.Value.ToString(), out var campaignId)) + { + return; + } + + SelectedCampaignId = campaignId; + await JS.InvokeVoidAsync("rpgRollerApi.setSessionValue", CampaignSessionKey, campaignId.ToString()); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + } + + private async Task CreateCampaignAsync() + { + CampaignErrors.Clear(); + CampaignFormError = null; + + if (string.IsNullOrWhiteSpace(CampaignForm.Name)) + { + CampaignErrors["name"] = "Campaign name is required."; + } + + if (string.IsNullOrWhiteSpace(CampaignForm.RulesetId)) + { + CampaignErrors["rulesetId"] = "Ruleset is required."; + } + + if (CampaignErrors.Count > 0) + { + CampaignFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + var campaign = await RequestAsync("POST", "/api/campaigns", new CreateCampaignRequest(CampaignForm.Name.Trim(), CampaignForm.RulesetId)); + CampaignForm.Name = string.Empty; + await ReloadCampaignsAsync(campaign.Id); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Campaign created.", false); + } + catch (ApiRequestException ex) + { + CampaignFormError = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private void OpenCreateCharacterModal() + { + if (SelectedCampaignId.HasValue) + { + CharacterForm.CampaignId = SelectedCampaignId.Value.ToString(); + } + + CharacterForm.Name = string.Empty; + CharacterErrors.Clear(); + CharacterFormError = null; + ShowCreateCharacterModal = true; + } + + private void OpenEditCharacterModal(CharacterSummary character) + { + EditingCharacterId = character.Id; + EditCharacterForm.Name = character.Name; + EditCharacterForm.CampaignId = character.CampaignId.ToString(); + EditCharacterErrors.Clear(); + EditCharacterFormError = null; + ShowEditCharacterModal = true; + } + + private void CloseCharacterModals() + { + ShowCreateCharacterModal = false; + ShowEditCharacterModal = false; + EditingCharacterId = null; + } + + private async Task CreateCharacterAsync() + { + CharacterErrors.Clear(); + CharacterFormError = null; + + if (string.IsNullOrWhiteSpace(CharacterForm.Name)) + { + CharacterErrors["name"] = "Character name is required."; + } + + if (!Guid.TryParse(CharacterForm.CampaignId, out var campaignId)) + { + CharacterErrors["campaignId"] = "Campaign is required."; + } + + if (CharacterErrors.Count > 0) + { + CharacterFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync("POST", "/api/characters", new CreateCharacterRequest(CharacterForm.Name.Trim(), campaignId)); + CloseCharacterModals(); + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character created.", false); + } + catch (ApiRequestException ex) + { + CharacterFormError = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task UpdateCharacterAsync() + { + EditCharacterErrors.Clear(); + EditCharacterFormError = null; + + if (!EditingCharacterId.HasValue) + { + EditCharacterFormError = "No character selected."; + return; + } + + if (string.IsNullOrWhiteSpace(EditCharacterForm.Name)) + { + EditCharacterErrors["name"] = "Character name is required."; + } + + if (!Guid.TryParse(EditCharacterForm.CampaignId, out var campaignId)) + { + EditCharacterErrors["campaignId"] = "Campaign is required."; + } + + if (EditCharacterErrors.Count > 0) + { + EditCharacterFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + var updatedCharacter = await RequestAsync("PUT", $"/api/characters/{EditingCharacterId.Value}", new UpdateCharacterRequest(EditCharacterForm.Name.Trim(), campaignId)); + CloseCharacterModals(); + await ReloadCampaignsAsync(updatedCharacter.CampaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character updated.", false); + } + catch (ApiRequestException ex) + { + EditCharacterFormError = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task ActivateCharacterAsync(Guid characterId) + { + IsMutating = true; + try + { + await RequestWithoutPayloadAsync("POST", $"/api/characters/{characterId}/activate"); + ActiveCharacterId = characterId; + SelectedCharacterId = characterId; + await RefreshCampaignScopeAsync(); + SetStatus("Active character updated.", false); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + + private void OpenCreateSkillModal() + { + SkillForm.Name = string.Empty; + SkillForm.DiceRollDefinition = string.Empty; + SkillErrors.Clear(); + SkillFormError = null; + ShowCreateSkillModal = true; + } + + private void OpenEditSkillModal() + { + if (SelectedSkill is null) + { + return; + } + + EditingSkillId = SelectedSkill.Id; + EditSkillForm.Name = SelectedSkill.Name; + EditSkillForm.DiceRollDefinition = SelectedSkill.DiceRollDefinition; + EditSkillErrors.Clear(); + EditSkillFormError = null; + ShowEditSkillModal = true; + } + + private void CloseSkillModals() + { + ShowCreateSkillModal = false; + ShowEditSkillModal = false; + EditingSkillId = null; + } + + private async Task CreateSkillAsync() + { + SkillErrors.Clear(); + SkillFormError = null; + + if (SelectedCharacter is null) + { + SkillFormError = "Select a character first."; + return; + } + + if (string.IsNullOrWhiteSpace(SkillForm.Name)) + { + SkillErrors["name"] = "Skill name is required."; + } + + if (string.IsNullOrWhiteSpace(SkillForm.DiceRollDefinition)) + { + SkillErrors["diceRollDefinition"] = "Expression is required."; + } + + if (SkillErrors.Count > 0) + { + SkillFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync("POST", $"/api/characters/{SelectedCharacter.Id}/skills", new CreateSkillRequest(SkillForm.Name.Trim(), SkillForm.DiceRollDefinition.Trim())); + CloseSkillModals(); + await RefreshCampaignScopeAsync(); + SetStatus("Skill created.", false); + } + catch (ApiRequestException ex) + { + SkillFormError = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task UpdateSkillAsync() + { + EditSkillErrors.Clear(); + EditSkillFormError = null; + + if (!EditingSkillId.HasValue) + { + EditSkillFormError = "No skill selected."; + return; + } + + if (string.IsNullOrWhiteSpace(EditSkillForm.Name)) + { + EditSkillErrors["name"] = "Skill name is required."; + } + + if (string.IsNullOrWhiteSpace(EditSkillForm.DiceRollDefinition)) + { + EditSkillErrors["diceRollDefinition"] = "Expression is required."; + } + + if (EditSkillErrors.Count > 0) + { + EditSkillFormError = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + var updatedSkill = await RequestAsync("PUT", $"/api/skills/{EditingSkillId.Value}", new UpdateSkillRequest(EditSkillForm.Name.Trim(), EditSkillForm.DiceRollDefinition.Trim())); + SelectedSkillId = updatedSkill.Id; + CloseSkillModals(); + await RefreshCampaignScopeAsync(); + SetStatus("Skill updated.", false); + } + catch (ApiRequestException ex) + { + EditSkillFormError = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task RollSelectedSkillAsync() + { + if (SelectedSkill is null) + { + SetStatus("Select a skill to roll.", true); + return; + } + + IsMutating = true; + try + { + LastRoll = await RequestAsync("POST", $"/api/skills/{SelectedSkill.Id}/roll", new RollSkillRequest(RollVisibility)); + await RefreshCampaignScopeAsync(); + SetStatus("Roll recorded.", false); + Announce("Roll result updated."); + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + finally + { + IsMutating = false; + } + } + + private void SelectCharacter(Guid characterId) + { + SelectedCharacterId = characterId; + SyncSelectedSkill(); + } + + private void SelectSkill(Guid skillId) + { + SelectedSkillId = skillId; + } + + private bool CanEditCharacter(CharacterSummary character) + { + return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm); + } + + private bool CanActivateCharacter(CharacterSummary character) + { + return User is not null && character.OwnerUserId == User.Id; + } + + private bool CanEditSkill(SkillSummary skill) + { + if (SelectedCampaign is null) + { + return false; + } + + var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == skill.CharacterId); + return character is not null && CanEditCharacter(character); + } + + private bool CanRollSkill(SkillSummary skill) + { + return CanEditSkill(skill); + } + + [JSInvokable] + public async Task OnStateEventReceived(long _) + { + if (StateRefreshInProgress) + { + return; + } + + StateRefreshInProgress = true; + try + { + await RefreshCampaignScopeAsync(); + } + finally + { + StateRefreshInProgress = false; + await InvokeAsync(StateHasChanged); + } + } + + [JSInvokable] + public Task OnConnectionStateChanged(string state) + { + ConnectionState = state switch + { + "connected" => "connected", + "reconnecting" => "reconnecting", + _ => "offline" + }; + + if (ConnectionState == "reconnecting") + { + Announce("Reconnecting to live updates."); + } + + if (ConnectionState == "offline") + { + Announce("Live updates offline. Use manual refresh."); + } + + return InvokeAsync(StateHasChanged); + } + + private async Task SyncStateEventsAsync() + { + if (User is null || !SelectedCampaignId.HasValue) + { + await StopStateEventsAsync(); + ConnectionState = "offline"; + return; + } + + DotNetRef ??= DotNetObjectReference.Create(this); + await JS.InvokeVoidAsync("rpgRollerApi.startStateEvents", SelectedCampaignId.Value.ToString(), DotNetRef); + ConnectionState = "reconnecting"; + } + + private async Task StopStateEventsAsync() + { + try + { + await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); + } + catch (JSDisconnectedException) + { + } + } + + public async ValueTask DisposeAsync() + { + await StopStateEventsAsync(); + DotNetRef?.Dispose(); + } + + private async Task RequestAsync(string method, string path, object? payload = null) + { + var response = await JS.InvokeAsync("rpgRollerApi.request", method, path, payload); + if (!response.Ok) + { + throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); + } + + if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null) + { + return default!; + } + + return response.Data.Deserialize(JsonOptions)!; + } + + private async Task RequestWithoutPayloadAsync(string method, string path) + { + var response = await JS.InvokeAsync("rpgRollerApi.request", method, path, null); + if (!response.Ok) + { + throw new ApiRequestException(response.Status, response.Error ?? "Request failed."); + } + } + + private void SyncSelectedCharacter() + { + if (SelectedCampaign is null || SelectedCampaign.Characters.Count == 0) + { + SelectedCharacterId = null; + return; + } + + var candidateIds = SelectedCampaign.Characters.Select(c => c.Id).ToHashSet(); + if (SelectedCharacterId.HasValue && candidateIds.Contains(SelectedCharacterId.Value)) + { + return; + } + + if (ActiveCharacterId.HasValue && candidateIds.Contains(ActiveCharacterId.Value)) + { + SelectedCharacterId = ActiveCharacterId; + return; + } + + SelectedCharacterId = SelectedCampaign.Characters[0].Id; + } + + private void SyncSelectedSkill() + { + var skills = SelectedCharacterSkills; + if (skills.Count == 0) + { + SelectedSkillId = null; + return; + } + + if (SelectedSkillId.HasValue && skills.Any(skill => skill.Id == SelectedSkillId.Value)) + { + return; + } + + SelectedSkillId = skills[0].Id; + } + + private string OwnerLabel(Guid ownerUserId) + { + if (User is not null && ownerUserId == User.Id) + { + return "You"; + } + + if (SelectedCampaign is not null && ownerUserId == SelectedCampaign.Gm.Id) + { + return $"{SelectedCampaign.Gm.DisplayName} (GM)"; + } + + return ownerUserId.ToString("N")[..8]; + } + + private string CharacterLabel(Guid characterId) + { + return SelectedCampaign?.Characters.FirstOrDefault(c => c.Id == characterId)?.Name ?? "Hidden character"; + } + + private string SkillLabel(Guid skillId) + { + return SelectedCampaign?.Skills.FirstOrDefault(s => s.Id == skillId)?.Name ?? "Hidden skill"; + } + + private string RollerLabel(CampaignLogEntry entry) + { + if (User is not null && entry.RollerUserId == User.Id) + { + return "You"; + } + + if (SelectedCampaign is not null && entry.RollerUserId == SelectedCampaign.Gm.Id) + { + return "GM"; + } + + return "Participant"; + } + + private string VisibilityLabel(CampaignLogEntry entry) + { + if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return "Public"; + } + + if (User is not null && entry.RollerUserId == User.Id) + { + return "Private (you)"; + } + + if (IsCurrentUserGm) + { + return "Private (GM view)"; + } + + return "Private"; + } + + private string VisibilityBadgeCssClass(CampaignLogEntry entry) + { + if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return "public"; + } + + if (User is not null && entry.RollerUserId == User.Id) + { + return "private-self"; + } + + if (IsCurrentUserGm) + { + return "private-gm"; + } + + return "private-generic"; + } + + private string LogEntryCssClass(CampaignLogEntry entry) + { + if (!string.Equals(entry.Visibility, "private", StringComparison.OrdinalIgnoreCase)) + { + return "public"; + } + + if (User is not null && entry.RollerUserId == User.Id) + { + return "private-self"; + } + + if (IsCurrentUserGm) + { + return "private-gm"; + } + + return "private-generic"; + } + + private static string InitialsFor(string value) + { + var words = value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (words.Length == 0) + { + return "?"; + } + + if (words.Length == 1) + { + return words[0].Length >= 2 ? words[0][..2].ToUpperInvariant() : words[0].ToUpperInvariant(); + } + + return string.Concat(words[0][0], words[1][0]).ToUpperInvariant(); + } + + private void ClearAuthenticatedState() + { + User = null; + ActiveCharacterId = null; + SelectedCampaignId = null; + SelectedCampaign = null; + Campaigns = []; + CampaignLog = []; + SelectedCharacterId = null; + SelectedSkillId = null; + LastRoll = null; + ShowCreateCharacterModal = false; + ShowEditCharacterModal = false; + ShowCreateSkillModal = false; + ShowEditSkillModal = false; + } + + private void SetStatus(string message, bool isError) + { + StatusMessage = message; + StatusIsError = isError; + Announce(message); + } + + private void Announce(string message) + { + LiveAnnouncement = message; + } + + private sealed class RegisterFormModel + { + public string Username { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } + + private sealed class LoginFormModel + { + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; + } + + private sealed class CampaignFormModel + { + public string Name { get; set; } = string.Empty; + public string RulesetId { get; set; } = string.Empty; + } + + private sealed class CharacterFormModel + { + public string Name { get; set; } = string.Empty; + public string CampaignId { get; set; } = string.Empty; + } + + private sealed class SkillFormModel + { + public string Name { get; set; } = string.Empty; + public string DiceRollDefinition { get; set; } = string.Empty; + } + + private sealed class JsApiResponse + { + public bool Ok { get; set; } + public int Status { get; set; } + public string? Error { get; set; } + public JsonElement Data { get; set; } + } + + private sealed class ApiRequestException : Exception + { + public ApiRequestException(int statusCode, string message) + : base(message) + { + StatusCode = statusCode; + } + + public int StatusCode { get; } + } +} diff --git a/RpgRoller/Components/Routes.razor b/RpgRoller/Components/Routes.razor index dd87796..da2293f 100644 --- a/RpgRoller/Components/Routes.razor +++ b/RpgRoller/Components/Routes.razor @@ -1,3 +1,5 @@ +@attribute [ExcludeFromCodeCoverage] + diff --git a/RpgRoller/Program.cs b/RpgRoller/Program.cs index 8ed41b9..e796027 100644 --- a/RpgRoller/Program.cs +++ b/RpgRoller/Program.cs @@ -10,6 +10,7 @@ var app = builder.Build(); app.InitializeRpgRollerState(); app.UseStaticFiles(); +app.UseAntiforgery(); app.MapRpgRollerApi(); app.MapRazorComponents() diff --git a/RpgRoller/wwwroot/js/rpgroller-api.js b/RpgRoller/wwwroot/js/rpgroller-api.js index a30b436..6a6f845 100644 --- a/RpgRoller/wwwroot/js/rpgroller-api.js +++ b/RpgRoller/wwwroot/js/rpgroller-api.js @@ -1,4 +1,112 @@ window.rpgRollerApi = (() => { + const sessionPrefix = "rpgroller."; + const stateStream = { + source: null, + dotNetRef: null, + campaignId: null, + reconnectDelayMs: 1000, + reconnectTimer: null, + stopped: true + }; + + function clearReconnectTimer() { + if (stateStream.reconnectTimer) { + clearTimeout(stateStream.reconnectTimer); + stateStream.reconnectTimer = null; + } + } + + function invokeDotNet(method, ...args) { + if (!stateStream.dotNetRef) { + return; + } + + stateStream.dotNetRef.invokeMethodAsync(method, ...args).catch(() => { + }); + } + + function scheduleReconnect() { + if (stateStream.stopped || stateStream.reconnectTimer) { + return; + } + + const delay = stateStream.reconnectDelayMs; + stateStream.reconnectTimer = setTimeout(() => { + stateStream.reconnectTimer = null; + if (stateStream.stopped) { + return; + } + + stateStream.reconnectDelayMs = Math.min(stateStream.reconnectDelayMs * 2, 30000); + connectStateStream(); + }, delay); + } + + function connectStateStream() { + if (stateStream.stopped || !stateStream.campaignId) { + return; + } + + clearReconnectTimer(); + invokeDotNet("OnConnectionStateChanged", "reconnecting"); + + const source = new EventSource(`/api/events/state?campaignId=${encodeURIComponent(stateStream.campaignId)}`); + stateStream.source = source; + + source.onopen = () => { + stateStream.reconnectDelayMs = 1000; + invokeDotNet("OnConnectionStateChanged", "connected"); + }; + + source.addEventListener("state", (event) => { + try { + const payload = JSON.parse(event.data); + const version = typeof payload.version === "number" ? payload.version : 0; + invokeDotNet("OnStateEventReceived", version); + } + catch { + invokeDotNet("OnStateEventReceived", 0); + } + }); + + source.onerror = () => { + if (stateStream.source === source) { + source.close(); + stateStream.source = null; + } + + if (stateStream.stopped) { + return; + } + + invokeDotNet("OnConnectionStateChanged", "reconnecting"); + scheduleReconnect(); + }; + } + + function stopStateEvents() { + stateStream.stopped = true; + clearReconnectTimer(); + + if (stateStream.source) { + stateStream.source.close(); + stateStream.source = null; + } + + stateStream.campaignId = null; + stateStream.dotNetRef = null; + } + + window.addEventListener("offline", () => { + invokeDotNet("OnConnectionStateChanged", "offline"); + }); + + window.addEventListener("online", () => { + if (!stateStream.stopped) { + connectStateStream(); + } + }); + async function request(method, url, body) { const options = { method, @@ -51,7 +159,34 @@ window.rpgRollerApi = (() => { }; } + function getSessionValue(key) { + return sessionStorage.getItem(`${sessionPrefix}${key}`); + } + + function setSessionValue(key, value) { + const qualifiedKey = `${sessionPrefix}${key}`; + if (value === null || value === undefined || value === "") { + sessionStorage.removeItem(qualifiedKey); + return; + } + + sessionStorage.setItem(qualifiedKey, value); + } + + function startStateEvents(campaignId, dotNetRef) { + stopStateEvents(); + stateStream.stopped = false; + stateStream.dotNetRef = dotNetRef; + stateStream.campaignId = campaignId; + stateStream.reconnectDelayMs = 1000; + connectStateStream(); + } + return { - request + request, + getSessionValue, + setSessionValue, + startStateEvents, + stopStateEvents }; })(); diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css index fd18b30..f63ff09 100644 --- a/RpgRoller/wwwroot/styles.css +++ b/RpgRoller/wwwroot/styles.css @@ -1,66 +1,305 @@ +:root { + --bg-top: #f7f0d8; + --bg-bottom: #ecdfc4; + --card: #fffaf0; + --card-border: #c3b28b; + --text: #2b2418; + --muted: #6a5b3f; + --accent: #385f3d; + --accent-2: #9a402b; + --warn: #b4681b; + --danger: #8f2323; + --focus: #1d3f72; + --public: #2d6645; + --private-self: #4f3a8f; + --private-gm: #915119; +} + * { box-sizing: border-box; } +html, body { margin: 0; - min-height: 100vh; - font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; - background: linear-gradient(165deg, #f2f4f8 0%, #e6ebf5 100%); - color: #1f2937; + min-height: 100%; } -.layout { - max-width: 56rem; +body { + background: + radial-gradient(circle at 15% 10%, rgba(255, 255, 255, 0.32), transparent 45%), + linear-gradient(165deg, var(--bg-top), var(--bg-bottom)); + color: var(--text); + font-family: "Trebuchet MS", "Lucida Sans Unicode", "Segoe UI", sans-serif; + line-height: 1.4; +} + +h1, +h2, +h3 { + font-family: "Palatino Linotype", "Book Antiqua", Palatino, serif; + letter-spacing: 0.02em; +} + +.rr-app { + max-width: 78rem; margin: 0 auto; - padding: 2.5rem 1.25rem; + padding: 1rem 1rem 4.5rem; +} + +.loading-shell, +.auth-shell { + display: grid; + gap: 1rem; + max-width: 70rem; + margin: 0 auto; +} + +.auth-subtitle { + margin-top: 0; + color: var(--muted); +} + +.workspace-shell { display: grid; gap: 1rem; } -.panel { +.workspace-header { + position: sticky; + top: 0; + z-index: 10; display: grid; - gap: 0.6rem; - background: rgba(255, 255, 255, 0.8); - border: 1px solid #c8d2e6; - border-radius: 0.6rem; - padding: 0.9rem; + gap: 0.75rem; + grid-template-columns: 1.1fr 1.2fr 1fr; + background: linear-gradient(120deg, #f1e4c9, #efe0bf); + border: 1px solid var(--card-border); + border-radius: 0.8rem; + padding: 0.85rem; + backdrop-filter: blur(6px); } -.grid-form { +.header-group { display: grid; - gap: 0.5rem; + gap: 0.2rem; } -.inline-controls { +.brand h1 { + margin: 0; +} + +.brand p, +.context p { + margin: 0; +} + +.controls { + justify-items: end; +} + +.switch-group, +.header-actions, +.inline-actions { display: flex; - gap: 0.5rem; + gap: 0.45rem; + flex-wrap: wrap; +} + +.card { + background: color-mix(in srgb, var(--card) 94%, #ffffff 6%); + border: 1px solid var(--card-border); + border-radius: 0.8rem; + padding: 0.85rem; + display: grid; + gap: 0.75rem; +} + +.auth-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.form-grid { + display: grid; + gap: 0.35rem; +} + +label { + font-weight: 600; } input, select, button { font: inherit; - padding: 0.6rem 0.75rem; + border-radius: 0.45rem; + border: 1px solid #8e7b57; + padding: 0.55rem 0.65rem; +} + +input, +select { + background: #fffdf5; + color: var(--text); } button { - border: 0; - border-radius: 0.4rem; - background: #2563eb; - color: #ffffff; + background: linear-gradient(180deg, var(--accent), #2f4f34); + color: #f8f7ef; + border-color: transparent; cursor: pointer; } -.status, -.result { - font-weight: 600; +button.ghost { + background: transparent; + color: var(--text); + border-color: #8e7b57; } -.message { - min-height: 1.5rem; - color: #1d4ed8; - font-weight: 600; +button.switch { + background: transparent; + color: var(--text); + border-color: #8e7b57; +} + +button.switch.active { + background: var(--accent-2); + border-color: var(--accent-2); + color: #fff9ef; +} + +button:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +button:focus-visible, +input:focus-visible, +select:focus-visible { + outline: 3px solid var(--focus); + outline-offset: 2px; +} + +.status-message, +.form-error, +.field-error { + margin: 0; +} + +.status-message { + font-weight: 700; +} + +.status-message.success { + color: var(--public); +} + +.status-message.error, +.form-error, +.field-error { + color: var(--danger); +} + +.section-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; +} + +.play-screen { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(19rem, 1fr); + gap: 1rem; +} + +.management-screen { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1rem; +} + +.character-picker { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; +} + +.icon-tab { + display: grid; + place-items: center; + gap: 0.2rem; + min-width: 4.8rem; + padding: 0.45rem; + background: transparent; + color: var(--text); + border-color: #8e7b57; +} + +.icon-tab.active { + background: linear-gradient(145deg, #e9d4a4, #d7b672); + border-color: #9e7328; +} + +.icon-tab-glyph { + width: 2rem; + height: 2rem; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid #8e7b57; + font-weight: 700; +} + +.icon-tab-text { + font-size: 0.82rem; +} + +.character-sheet, +.skills-section, +.last-roll { + border: 1px dashed #a89066; + border-radius: 0.65rem; + padding: 0.65rem; + display: grid; + gap: 0.4rem; +} + +.skill-list { + display: grid; + gap: 0.35rem; +} + +.skill-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.6rem; + background: #f8f1df; + color: var(--text); + border: 1px solid #b39f79; +} + +.skill-item.active { + border-color: #7b5a1f; + background: #ecdfc2; +} + +.roll-panel { + display: grid; + gap: 0.4rem; +} + +.roll-total { + font-size: 1.8rem; + font-weight: 800; + margin: 0; +} + +.empty, +.muted { + color: var(--muted); } .log-list { @@ -68,11 +307,213 @@ button { margin: 0; padding: 0; display: grid; - gap: 0.35rem; + gap: 0.5rem; } -.log-item { - padding: 0.45rem 0.55rem; - border-radius: 0.35rem; - background: #f6f8fc; +.log-entry { + border: 1px solid #b8a37b; + border-radius: 0.55rem; + padding: 0.5rem; + background: #f8f0de; +} + +.log-entry.private-self { + border-color: #6252a8; + background: #ece7ff; +} + +.log-entry.private-gm { + border-color: #9a5f1e; + background: #fff1db; +} + +.log-meta { + display: flex; + justify-content: space-between; + gap: 0.5rem; + align-items: center; +} + +.badge { + display: inline-flex; + border-radius: 999px; + font-size: 0.78rem; + padding: 0.18rem 0.45rem; + border: 1px solid transparent; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.badge.active { + border-color: #8f5f12; + background: #f6d28d; + color: #5d3808; +} + +.badge.public { + background: #e1f2e3; + color: var(--public); +} + +.badge.private-self { + background: #e8ddfb; + color: var(--private-self); +} + +.badge.private-gm { + background: #f9e2be; + color: var(--private-gm); +} + +.badge.private-generic { + background: #f4e8d4; + color: #6f5832; +} + +.connection { + font-weight: 700; + margin: 0; +} + +.connection.ok { + color: var(--public); +} + +.connection.warn { + color: var(--warn); +} + +.connection.offline { + color: var(--danger); +} + +.skeleton-stack { + display: grid; + gap: 0.4rem; +} + +.skeleton-line { + height: 0.85rem; + border-radius: 0.4rem; + background: linear-gradient(90deg, #dfd2b7, #efe3c9, #dfd2b7); + background-size: 220% 100%; + animation: shimmer 1.1s linear infinite; +} + +.skeleton-line.short { + width: 65%; +} + +.health-banner { + border: 1px solid #b77a29; + background: #fff2db; + border-radius: 0.75rem; + padding: 0.75rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.management-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.45rem; +} + +.management-list li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.5rem; + border: 1px solid #b8a37b; + border-radius: 0.55rem; + padding: 0.5rem; +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(35, 25, 9, 0.55); + display: grid; + place-items: center; + z-index: 20; + padding: 1rem; +} + +.modal-card { + width: min(32rem, 100%); + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 0.85rem; + padding: 0.9rem; + display: grid; + gap: 0.65rem; +} + +.mobile-bottom-nav { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 30; + padding: 0.55rem; + display: none; + gap: 0.45rem; + background: rgba(241, 228, 201, 0.96); + border-top: 1px solid var(--card-border); +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} + +@keyframes shimmer { + from { + background-position: 220% 0; + } + + to { + background-position: -220% 0; + } +} + +@media (max-width: 1023px) { + .workspace-header { + position: static; + grid-template-columns: 1fr; + } + + .play-screen { + grid-template-columns: 1fr; + } + + .play-screen.mobile-log .character-panel { + display: none; + } + + .play-screen.mobile-character .log-panel { + display: none; + } + + .management-screen { + grid-template-columns: 1fr; + } + + .auth-grid { + grid-template-columns: 1fr; + } + + .mobile-bottom-nav { + display: flex; + } }