diff --git a/FAQ.md b/FAQ.md index 2dfe717..cb3145a 100644 --- a/FAQ.md +++ b/FAQ.md @@ -67,3 +67,7 @@ Roll responses and campaign log entries include per-die state flags (`crit`, `fu ## How is the active character chosen in the Play screen? There is no separate activate button in Play. The selected character in the character picker is treated as active context and the UI syncs that choice to the backend for owned characters. + +## Where did the Home page logic move after the refactor? + +`Home.razor` now focuses on composition and delegates behavior to concern-based code-behind partials (`Home.Auth.cs`, `Home.Campaign.cs`, `Home.Character.cs`, `Home.Skill.cs`, etc.) plus dedicated UI controls under `Components/Pages/HomeControls/`. diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md index 9a59d07..1e31c17 100644 --- a/FRONTEND_PROGRESS.md +++ b/FRONTEND_PROGRESS.md @@ -7,6 +7,7 @@ Tracking against `UX.md` tasks and decisions. - Branch: `feature/blazor-frontend-rebuild-ux` - Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`) - Legacy TypeScript frontend/runtime artifacts: removed +- Home page orchestration split by concern (`Home.*.cs` partials + `HomeControls/*`) to reduce merge churn and keep auth/campaign/character/skill flows isolated. ## UX Checklist diff --git a/README.md b/README.md index ed6683d..555a16a 100644 --- a/README.md +++ b/README.md @@ -26,8 +26,10 @@ Backend: Frontend: - `RpgRoller/Components/`: Blazor root app, routes, layout and page components -- `RpgRoller/Components/Pages/Home.razor(.cs)`: page composition + app state orchestration -- `RpgRoller/Components/Pages/HomeControls/`: play-screen UI controls extracted from `Home.razor` to reduce churn +- `RpgRoller/Components/Pages/Home.razor`: top-level page composition using concern-focused child controls +- `RpgRoller/Components/Pages/Home.*.cs`: concern-based partial class split (`State`, `Lifecycle`, `Auth`, `Campaign`, `Character`, `Skill`, `Realtime`, `Api`, `Presentation`, `Validation`) +- `RpgRoller/Components/Pages/Home.Models.cs`: reusable `FormState` + page form models +- `RpgRoller/Components/Pages/HomeControls/`: auth, campaign management, play-screen, and modal controls extracted from `Home.razor` - `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 diff --git a/RpgRoller/Components/Pages/Home.Api.cs b/RpgRoller/Components/Pages/Home.Api.cs new file mode 100644 index 0000000..bd57e7e --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Api.cs @@ -0,0 +1,51 @@ +using System.Text.Json; +using Microsoft.JSInterop; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + 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 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/Pages/Home.Auth.cs b/RpgRoller/Components/Pages/Home.Auth.cs new file mode 100644 index 0000000..e612df1 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Auth.cs @@ -0,0 +1,113 @@ +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + private async Task RegisterAsync() + { + RegisterState.ResetValidation(); + var model = RegisterState.Model; + + AddRequiredError(RegisterState.Errors, "username", model.Username, "Username is required."); + AddRequiredError(RegisterState.Errors, "displayName", model.DisplayName, "Display name is required."); + + if (string.IsNullOrWhiteSpace(model.Password) || model.Password.Length < 8) + { + RegisterState.Errors["password"] = "Password must be at least 8 characters."; + } + + if (RegisterState.Errors.Count > 0) + { + RegisterState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync( + "POST", + "/api/auth/register", + new RegisterRequest(model.Username.Trim(), model.Password, model.DisplayName.Trim())); + + model.Password = string.Empty; + SetStatus("Registration successful. You can log in now.", false); + } + catch (ApiRequestException ex) + { + if (ex.Message.Contains("already taken", StringComparison.OrdinalIgnoreCase)) + { + RegisterState.Errors["username"] = "Username is already taken. Choose another one."; + return; + } + + RegisterState.ErrorMessage = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task LoginAsync() + { + LoginState.ResetValidation(); + var model = LoginState.Model; + + AddRequiredError(LoginState.Errors, "username", model.Username, "Username is required."); + AddRequiredError(LoginState.Errors, "password", model.Password, "Password is required."); + + if (LoginState.Errors.Count > 0) + { + LoginState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync( + "POST", + "/api/auth/login", + new LoginRequest(model.Username.Trim(), model.Password)); + + model.Password = string.Empty; + await ReloadAuthenticatedSessionAsync(null); + SetStatus("Logged in.", false); + } + catch (ApiRequestException ex) + { + LoginState.ErrorMessage = 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); + } +} diff --git a/RpgRoller/Components/Pages/Home.Campaign.cs b/RpgRoller/Components/Pages/Home.Campaign.cs new file mode 100644 index 0000000..7ec5ea0 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Campaign.cs @@ -0,0 +1,91 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + 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 (!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() + { + CampaignState.ResetValidation(); + var model = CampaignState.Model; + + AddRequiredError(CampaignState.Errors, "name", model.Name, "Campaign name is required."); + AddRequiredError(CampaignState.Errors, "rulesetId", model.RulesetId, "Ruleset is required."); + + if (CampaignState.Errors.Count > 0) + { + CampaignState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + var campaign = await RequestAsync( + "POST", + "/api/campaigns", + new CreateCampaignRequest(model.Name.Trim(), model.RulesetId)); + + model.Name = string.Empty; + await ReloadCampaignsAsync(campaign.Id); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Campaign created.", false); + } + catch (ApiRequestException ex) + { + CampaignState.ErrorMessage = ex.Message; + } + finally + { + IsMutating = false; + } + } +} diff --git a/RpgRoller/Components/Pages/Home.Character.cs b/RpgRoller/Components/Pages/Home.Character.cs new file mode 100644 index 0000000..05a5d45 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Character.cs @@ -0,0 +1,167 @@ +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + private void OpenCreateCharacterModal() + { + var model = CreateCharacterState.Model; + model.Name = string.Empty; + model.CampaignId = SelectedCampaignId?.ToString() ?? string.Empty; + CreateCharacterState.ResetValidation(); + ShowCreateCharacterModal = true; + } + + private void OpenEditCharacterModal(CharacterSummary character) + { + EditingCharacterId = character.Id; + + var model = EditCharacterState.Model; + model.Name = character.Name; + model.CampaignId = character.CampaignId.ToString(); + + EditCharacterState.ResetValidation(); + ShowEditCharacterModal = true; + } + + private void CloseCharacterModals() + { + ShowCreateCharacterModal = false; + ShowEditCharacterModal = false; + EditingCharacterId = null; + } + + private async Task CreateCharacterAsync() + { + CreateCharacterState.ResetValidation(); + var model = CreateCharacterState.Model; + + AddRequiredError(CreateCharacterState.Errors, "name", model.Name, "Character name is required."); + var hasCampaignId = TryParseGuid( + model.CampaignId, + CreateCharacterState.Errors, + "campaignId", + "Campaign is required.", + out var campaignId); + + if (CreateCharacterState.Errors.Count > 0 || !hasCampaignId) + { + CreateCharacterState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync( + "POST", + "/api/characters", + new CreateCharacterRequest(model.Name.Trim(), campaignId)); + + CloseCharacterModals(); + await ReloadCampaignsAsync(campaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character created.", false); + } + catch (ApiRequestException ex) + { + CreateCharacterState.ErrorMessage = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task UpdateCharacterAsync() + { + EditCharacterState.ResetValidation(); + + if (!EditingCharacterId.HasValue) + { + EditCharacterState.ErrorMessage = "No character selected."; + return; + } + + var model = EditCharacterState.Model; + AddRequiredError(EditCharacterState.Errors, "name", model.Name, "Character name is required."); + var hasCampaignId = TryParseGuid( + model.CampaignId, + EditCharacterState.Errors, + "campaignId", + "Campaign is required.", + out var campaignId); + + if (EditCharacterState.Errors.Count > 0 || !hasCampaignId) + { + EditCharacterState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + var updatedCharacter = await RequestAsync( + "PUT", + $"/api/characters/{EditingCharacterId.Value}", + new UpdateCharacterRequest(model.Name.Trim(), campaignId)); + + CloseCharacterModals(); + await ReloadCampaignsAsync(updatedCharacter.CampaignId); + await RefreshCampaignScopeAsync(); + await SyncStateEventsAsync(); + SetStatus("Character updated.", false); + } + catch (ApiRequestException ex) + { + EditCharacterState.ErrorMessage = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task SelectCharacterAsync(Guid characterId) + { + SelectedCharacterId = characterId; + SyncSelectedSkill(); + await EnsureSelectedCharacterActiveAsync(); + } + + private bool CanEditCharacter(CharacterSummary character) + { + return User is not null && (character.OwnerUserId == User.Id || IsCurrentUserGm); + } + + private static bool CanActivateCharacter(CharacterSummary character, UserSummary? user) + { + return user is not null && character.OwnerUserId == user.Id; + } + + private async Task EnsureSelectedCharacterActiveAsync() + { + if (!SelectedCharacterId.HasValue || SelectedCampaign is null) + { + return; + } + + var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); + if (character is null || !CanActivateCharacter(character, User) || ActiveCharacterId == character.Id) + { + return; + } + + try + { + await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); + ActiveCharacterId = character.Id; + } + catch (ApiRequestException ex) + { + SetStatus(ex.Message, true); + } + } +} diff --git a/RpgRoller/Components/Pages/Home.Lifecycle.cs b/RpgRoller/Components/Pages/Home.Lifecycle.cs new file mode 100644 index 0000000..c140cba --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Lifecycle.cs @@ -0,0 +1,206 @@ +using RpgRoller.Contracts; +using Microsoft.JSInterop; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + protected override async Task OnAfterRenderAsync(bool firstRender) + { + HasInteractiveRenderStarted = true; + 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(CampaignState.Model.RulesetId)) + { + CampaignState.Model.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 + { + var campaignId = SelectedCampaignId.Value; + SelectedCampaign = await RequestAsync("GET", $"/api/campaigns/{campaignId}"); + CampaignLog = (await RequestAsync>("GET", $"/api/campaigns/{campaignId}/log")).ToList(); + SyncSelectedCharacter(); + SyncSelectedSkill(); + await EnsureSelectedCharacterActiveAsync(); + } + 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; + } + } +} diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs new file mode 100644 index 0000000..ab327e8 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -0,0 +1,55 @@ +namespace RpgRoller.Components.Pages; + +public sealed class FormState + where TModel : new() +{ + public TModel Model { get; } = new(); + public Dictionary Errors { get; } = []; + public string? ErrorMessage { get; set; } + + public void ResetValidation() + { + Errors.Clear(); + ErrorMessage = null; + } +} + +public sealed class RegisterFormModel +{ + public string Username { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public sealed class LoginFormModel +{ + public string Username { get; set; } = string.Empty; + public string Password { get; set; } = string.Empty; +} + +public sealed class CampaignFormModel +{ + public string Name { get; set; } = string.Empty; + public string RulesetId { get; set; } = string.Empty; +} + +public sealed class CharacterFormModel +{ + public string Name { get; set; } = string.Empty; + public string CampaignId { get; set; } = string.Empty; +} + +public sealed class SkillFormModel +{ + public string Name { get; set; } = string.Empty; + public string DiceRollDefinition { get; set; } = string.Empty; + public int WildDice { get; set; } + public bool AllowFumble { get; set; } +} + +public enum HomeViewMode +{ + Loading, + Anonymous, + Workspace +} diff --git a/RpgRoller/Components/Pages/Home.Presentation.cs b/RpgRoller/Components/Pages/Home.Presentation.cs new file mode 100644 index 0000000..557118d --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Presentation.cs @@ -0,0 +1,171 @@ +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + 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 SkillDefinitionLabel(SkillSummary skill) + { + if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) + { + return skill.DiceRollDefinition; + } + + var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off"; + return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumbleLabel}"; + } + + 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)"; + } + + return IsCurrentUserGm ? "Private (GM view)" : "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"; + } + + return IsCurrentUserGm ? "private-gm" : "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"; + } + + return IsCurrentUserGm ? "private-gm" : "private-generic"; + } + + 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; + } +} diff --git a/RpgRoller/Components/Pages/Home.Realtime.cs b/RpgRoller/Components/Pages/Home.Realtime.cs new file mode 100644 index 0000000..59e8f89 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Realtime.cs @@ -0,0 +1,93 @@ +using Microsoft.JSInterop; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + [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() + { + if (!HasInteractiveRenderStarted) + { + return; + } + + try + { + await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); + } + catch (JSDisconnectedException) + { + } + catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) + { + } + } + + public async ValueTask DisposeAsync() + { + await StopStateEventsAsync(); + DotNetRef?.Dispose(); + } + + private static bool IsStaticRenderInteropException(InvalidOperationException exception) + { + return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/RpgRoller/Components/Pages/Home.Skill.cs b/RpgRoller/Components/Pages/Home.Skill.cs new file mode 100644 index 0000000..562c337 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Skill.cs @@ -0,0 +1,196 @@ +using RpgRoller.Contracts; + +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + private void OpenCreateSkillModal() + { + var model = CreateSkillState.Model; + model.Name = string.Empty; + model.DiceRollDefinition = string.Empty; + model.WildDice = IsSelectedCampaignD6 ? 1 : 0; + model.AllowFumble = IsSelectedCampaignD6; + + CreateSkillState.ResetValidation(); + ShowCreateSkillModal = true; + } + + private void OpenEditSkillModal() + { + if (SelectedSkill is null) + { + return; + } + + EditingSkillId = SelectedSkill.Id; + + var model = EditSkillState.Model; + model.Name = SelectedSkill.Name; + model.DiceRollDefinition = SelectedSkill.DiceRollDefinition; + model.WildDice = SelectedSkill.WildDice; + model.AllowFumble = SelectedSkill.AllowFumble; + + EditSkillState.ResetValidation(); + ShowEditSkillModal = true; + } + + private void CloseSkillModals() + { + ShowCreateSkillModal = false; + ShowEditSkillModal = false; + EditingSkillId = null; + } + + private async Task CreateSkillAsync() + { + CreateSkillState.ResetValidation(); + + if (SelectedCharacter is null) + { + CreateSkillState.ErrorMessage = "Select a character first."; + return; + } + + var model = CreateSkillState.Model; + AddRequiredError(CreateSkillState.Errors, "name", model.Name, "Skill name is required."); + AddRequiredError(CreateSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); + + if (IsSelectedCampaignD6 && model.WildDice < 1) + { + CreateSkillState.Errors["wildDice"] = "D6 skills require at least one wild die."; + } + + if (CreateSkillState.Errors.Count > 0) + { + CreateSkillState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + _ = await RequestAsync( + "POST", + $"/api/characters/{SelectedCharacter.Id}/skills", + new CreateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble)); + + CloseSkillModals(); + await RefreshCampaignScopeAsync(); + SetStatus("Skill created.", false); + } + catch (ApiRequestException ex) + { + CreateSkillState.ErrorMessage = ex.Message; + } + finally + { + IsMutating = false; + } + } + + private async Task UpdateSkillAsync() + { + EditSkillState.ResetValidation(); + + if (!EditingSkillId.HasValue) + { + EditSkillState.ErrorMessage = "No skill selected."; + return; + } + + var model = EditSkillState.Model; + AddRequiredError(EditSkillState.Errors, "name", model.Name, "Skill name is required."); + AddRequiredError(EditSkillState.Errors, "diceRollDefinition", model.DiceRollDefinition, "Expression is required."); + + if (IsSelectedCampaignD6 && model.WildDice < 1) + { + EditSkillState.Errors["wildDice"] = "D6 skills require at least one wild die."; + } + + if (EditSkillState.Errors.Count > 0) + { + EditSkillState.ErrorMessage = "Resolve validation issues before submitting."; + return; + } + + IsMutating = true; + try + { + var updatedSkill = await RequestAsync( + "PUT", + $"/api/skills/{EditingSkillId.Value}", + new UpdateSkillRequest(model.Name.Trim(), model.DiceRollDefinition.Trim(), model.WildDice, model.AllowFumble)); + + SelectedSkillId = updatedSkill.Id; + CloseSkillModals(); + await RefreshCampaignScopeAsync(); + SetStatus("Skill updated.", false); + } + catch (ApiRequestException ex) + { + EditSkillState.ErrorMessage = 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 Task OnRollVisibilityChanged(string visibility) + { + RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; + return Task.CompletedTask; + } + + private void SelectSkill(Guid skillId) + { + SelectedSkillId = skillId; + } + + 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); + } +} diff --git a/RpgRoller/Components/Pages/Home.State.cs b/RpgRoller/Components/Pages/Home.State.cs new file mode 100644 index 0000000..607ae8f --- /dev/null +++ b/RpgRoller/Components/Pages/Home.State.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +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 FormState RegisterState { get; } = new(); + private FormState LoginState { get; } = new(); + private FormState CampaignState { get; } = new(); + private FormState CreateCharacterState { get; } = new(); + private FormState EditCharacterState { get; } = new(); + private FormState CreateSkillState { get; } = new(); + private FormState EditSkillState { get; } = new(); + + 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 bool HasInteractiveRenderStarted { 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 == SelectedCharacterId)?.Name; + + private bool IsCurrentUserGm => + SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; + + private bool IsSelectedCampaignD6 => + string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); + + private List SelectedCharacterSkills => + SelectedCampaign is null || !SelectedCharacterId.HasValue + ? [] + : SelectedCampaign.Skills + .Where(skill => skill.CharacterId == SelectedCharacterId.Value) + .OrderBy(skill => skill.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + private HomeViewMode CurrentView => + !IsInitialized + ? HomeViewMode.Loading + : User is null + ? HomeViewMode.Anonymous + : HomeViewMode.Workspace; + + private bool IsPlayScreen => string.Equals(CurrentScreen, "play", StringComparison.OrdinalIgnoreCase); + private bool IsManagementScreen => !IsPlayScreen; + + private string ConnectionStateLabel => ConnectionState switch + { + "connected" => "Connected", + "reconnecting" => "Reconnecting", + _ => "Offline fallback" + }; + + private string ConnectionStateCssClass => ConnectionState switch + { + "connected" => "ok", + "reconnecting" => "warn", + _ => "offline" + }; + + private string AppCssClass => + User is not null && IsPlayScreen ? "rr-app app-play" : "rr-app"; +} diff --git a/RpgRoller/Components/Pages/Home.Validation.cs b/RpgRoller/Components/Pages/Home.Validation.cs new file mode 100644 index 0000000..0e02020 --- /dev/null +++ b/RpgRoller/Components/Pages/Home.Validation.cs @@ -0,0 +1,23 @@ +namespace RpgRoller.Components.Pages; + +public partial class Home +{ + private static void AddRequiredError(Dictionary errors, string field, string? value, string message) + { + if (string.IsNullOrWhiteSpace(value)) + { + errors[field] = message; + } + } + + private static bool TryParseGuid(string? rawValue, Dictionary errors, string field, string message, out Guid parsed) + { + if (Guid.TryParse(rawValue, out parsed)) + { + return true; + } + + errors[field] = message; + return false; + } +} diff --git a/RpgRoller/Components/Pages/Home.razor b/RpgRoller/Components/Pages/Home.razor index 0fb91d5..bd7e145 100644 --- a/RpgRoller/Components/Pages/Home.razor +++ b/RpgRoller/Components/Pages/Home.razor @@ -5,92 +5,38 @@

@LiveAnnouncement

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

RpgRoller

-

Connecting...

-
+ } - else + + @switch (CurrentView) { - @if (HasHealthIssue) - { - - } - - @if (User is null) - { -
+ case HomeViewMode.Loading: +

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

- } - -
-
-
+

Connecting...

- } - else - { + break; + + case HomeViewMode.Anonymous: + + break; + + case HomeViewMode.Workspace:
@@ -98,7 +44,7 @@

Tabletop utility cockpit

-

@User.DisplayName (@User.Username)

+

@User!.DisplayName (@User.Username)

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

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

@@ -120,7 +66,7 @@

@StatusMessage

} - @if (CurrentScreen == "play") + @if (IsPlayScreen) {
Log } - else + + @if (IsManagementScreen) { -
-
-

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)

    -
    - -
    -
  • - } -
- } -
-
+ }
- } + break; }
-@if (ShowCreateCharacterModal) -{ - -} + -@if (ShowEditCharacterModal) -{ - -} + -@if (ShowCreateSkillModal) -{ - -} + -@if (ShowEditSkillModal) -{ - -} + diff --git a/RpgRoller/Components/Pages/Home.razor.cs b/RpgRoller/Components/Pages/Home.razor.cs index 47fe856..c1b7b09 100644 --- a/RpgRoller/Components/Pages/Home.razor.cs +++ b/RpgRoller/Components/Pages/Home.razor.cs @@ -1,1209 +1,5 @@ -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 bool HasInteractiveRenderStarted { 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 == SelectedCharacterId)?.Name; - private bool IsCurrentUserGm => SelectedCampaign is not null && User is not null && SelectedCampaign.Gm.Id == User.Id; - private bool IsSelectedCampaignD6 => string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase); - - 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" - }; - - private string AppCssClass => User is not null && CurrentScreen == "play" ? "rr-app app-play" : "rr-app"; - - protected override async Task OnAfterRenderAsync(bool firstRender) - { - HasInteractiveRenderStarted = true; - - 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(); - await EnsureSelectedCharacterActiveAsync(); - } - 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 void OpenCreateSkillModal() - { - SkillForm.Name = string.Empty; - SkillForm.DiceRollDefinition = string.Empty; - SkillForm.WildDice = IsSelectedCampaignD6 ? 1 : 0; - SkillForm.AllowFumble = IsSelectedCampaignD6; - 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; - EditSkillForm.WildDice = SelectedSkill.WildDice; - EditSkillForm.AllowFumble = SelectedSkill.AllowFumble; - 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 (IsSelectedCampaignD6 && SkillForm.WildDice < 1) - { - SkillErrors["wildDice"] = "D6 skills require at least one wild die."; - } - - 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(), SkillForm.WildDice, SkillForm.AllowFumble)); - 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 (IsSelectedCampaignD6 && EditSkillForm.WildDice < 1) - { - EditSkillErrors["wildDice"] = "D6 skills require at least one wild die."; - } - - 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(), EditSkillForm.WildDice, EditSkillForm.AllowFumble)); - 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 Task OnRollVisibilityChanged(string visibility) - { - RollVisibility = string.Equals(visibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; - return Task.CompletedTask; - } - - private async Task SelectCharacterAsync(Guid characterId) - { - SelectedCharacterId = characterId; - SyncSelectedSkill(); - await EnsureSelectedCharacterActiveAsync(); - } - - 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 async Task EnsureSelectedCharacterActiveAsync() - { - if (!SelectedCharacterId.HasValue || SelectedCampaign is null) - { - return; - } - - var character = SelectedCampaign.Characters.FirstOrDefault(c => c.Id == SelectedCharacterId.Value); - if (character is null || !CanActivateCharacter(character)) - { - return; - } - - if (ActiveCharacterId == character.Id) - { - return; - } - - try - { - await RequestWithoutPayloadAsync("POST", $"/api/characters/{character.Id}/activate"); - ActiveCharacterId = character.Id; - } - catch (ApiRequestException ex) - { - SetStatus(ex.Message, true); - } - } - - 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() - { - if (!HasInteractiveRenderStarted) - { - return; - } - - try - { - await JS.InvokeVoidAsync("rpgRollerApi.stopStateEvents"); - } - catch (JSDisconnectedException) - { - } - catch (InvalidOperationException ex) when (IsStaticRenderInteropException(ex)) - { - } - } - - public async ValueTask DisposeAsync() - { - await StopStateEventsAsync(); - DotNetRef?.Dispose(); - } - - private static bool IsStaticRenderInteropException(InvalidOperationException exception) - { - return exception.Message.Contains("statically rendered", StringComparison.OrdinalIgnoreCase); - } - - 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 SkillDefinitionLabel(SkillSummary skill) - { - if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase)) - { - return skill.DiceRollDefinition; - } - - var fumble = skill.AllowFumble ? "fumble on" : "fumble off"; - return $"{skill.DiceRollDefinition} | wild {skill.WildDice}, {fumble}"; - } - - 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 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; - public int WildDice { get; set; } - public bool AllowFumble { get; set; } - } - - 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/Pages/HomeControls/AuthSection.razor b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor new file mode 100644 index 0000000..c5fdda6 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/AuthSection.razor @@ -0,0 +1,98 @@ +@using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components.Pages +@attribute [ExcludeFromCodeCoverage] + +
+

RpgRoller

+

Register or log in to join a campaign session.

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

@StatusMessage

+ } +
+
+

Register

+ @if (!string.IsNullOrWhiteSpace(RegisterState.ErrorMessage)) + { +

@RegisterState.ErrorMessage

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

@registerUsernameError

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

@registerDisplayNameError

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

@registerPasswordError

+ } + +
+
+ +
+

Login

+ @if (!string.IsNullOrWhiteSpace(LoginState.ErrorMessage)) + { +

@LoginState.ErrorMessage

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

@loginUsernameError

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

@loginPasswordError

+ } + +
+
+
+
+ +@code { + [Parameter] + public FormState RegisterState { get; set; } = new(); + + [Parameter] + public FormState LoginState { get; set; } = new(); + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public string? StatusMessage { get; set; } + + [Parameter] + public bool StatusIsError { get; set; } + + [Parameter] + public EventCallback RegisterSubmitted { get; set; } + + [Parameter] + public EventCallback LoginSubmitted { get; set; } + + private async Task SubmitRegisterAsync() + { + await RegisterSubmitted.InvokeAsync(); + } + + private async Task SubmitLoginAsync() + { + await LoginSubmitted.InvokeAsync(); + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor new file mode 100644 index 0000000..26ae5e1 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/CampaignManagementPanel.razor @@ -0,0 +1,139 @@ +@using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components.Pages +@using RpgRoller.Contracts +@attribute [ExcludeFromCodeCoverage] + +
+
+

Campaign Selector

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

No campaigns yet.

+ } + else + { + + + } +

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

+
+ +
+

Create Campaign

+ @if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage)) + { +

@CampaignState.ErrorMessage

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

@campaignNameError

+ } + + + @if (CampaignState.Errors.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)

    +
    + +
    +
  • + } +
+ } +
+
+ +@code { + [Parameter] + public IReadOnlyList Campaigns { get; set; } = []; + + [Parameter] + public Guid? SelectedCampaignId { get; set; } + + [Parameter] + public string? SelectedCampaignName { get; set; } + + [Parameter] + public CampaignDetails? SelectedCampaign { get; set; } + + [Parameter] + public FormState CampaignState { get; set; } = new(); + + [Parameter] + public IReadOnlyList Rulesets { get; set; } = []; + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public Func OwnerLabel { get; set; } = _ => string.Empty; + + [Parameter] + public Func CanEditCharacter { get; set; } = _ => false; + + [Parameter] + public EventCallback CampaignSelectionChanged { get; set; } + + [Parameter] + public EventCallback CreateCampaignSubmitted { get; set; } + + [Parameter] + public EventCallback CreateCharacterRequested { get; set; } + + [Parameter] + public EventCallback EditCharacterRequested { get; set; } +} diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor new file mode 100644 index 0000000..82d42e5 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/CharacterFormModal.razor @@ -0,0 +1,78 @@ +@using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components.Pages +@using RpgRoller.Contracts +@attribute [ExcludeFromCodeCoverage] + +@if (Visible) +{ + +} + +@code { + [Parameter] + public bool Visible { get; set; } + + [Parameter] + public string Title { get; set; } = "Character"; + + [Parameter] + public string SubmitLabel { get; set; } = "Save"; + + [Parameter] + public string NameInputId { get; set; } = "character-name"; + + [Parameter] + public string CampaignInputId { get; set; } = "character-campaign"; + + [Parameter] + public FormState FormState { get; set; } = new(); + + [Parameter] + public IReadOnlyList Campaigns { get; set; } = []; + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public EventCallback SubmitRequested { get; set; } + + [Parameter] + public EventCallback CancelRequested { get; set; } + + private async Task SubmitAsync() + { + await SubmitRequested.InvokeAsync(); + } +} diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor new file mode 100644 index 0000000..55f08f7 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor @@ -0,0 +1,88 @@ +@using System.Diagnostics.CodeAnalysis +@using RpgRoller.Components.Pages +@attribute [ExcludeFromCodeCoverage] + +@if (Visible) +{ + +} + +@code { + [Parameter] + public bool Visible { get; set; } + + [Parameter] + public bool IsD6 { get; set; } + + [Parameter] + public string Title { get; set; } = "Skill"; + + [Parameter] + public string SubmitLabel { get; set; } = "Save"; + + [Parameter] + public string NameInputId { get; set; } = "skill-name"; + + [Parameter] + public string ExpressionInputId { get; set; } = "skill-expression"; + + [Parameter] + public string WildDiceInputId { get; set; } = "skill-wild"; + + [Parameter] + public string AllowFumbleInputId { get; set; } = "skill-fumble"; + + [Parameter] + public FormState FormState { get; set; } = new(); + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public EventCallback SubmitRequested { get; set; } + + [Parameter] + public EventCallback CancelRequested { get; set; } + + private async Task SubmitAsync() + { + await SubmitRequested.InvokeAsync(); + } +} diff --git a/TECH.md b/TECH.md index 308a9de..a55a6c0 100644 --- a/TECH.md +++ b/TECH.md @@ -91,7 +91,9 @@ This pattern is a strong baseline for low to medium scale and should be the defa ### 2.6 Frontend architecture -- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor(.cs)`. +- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor`. +- Home page logic split by concern with partials (`Home.State/Auth/Campaign/Character/Skill/Lifecycle/Realtime/Api/Presentation/Validation.cs`) to keep churn localized. +- Form UX state uses reusable `FormState` containers (`Home.Models.cs`) rather than parallel form/error/message property sets. - Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop. - UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection. - SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback.