Refactor Home page into concern-based components and partials

This commit is contained in:
2026-02-26 09:42:06 +01:00
parent 2d1bf9b9b7
commit b17490e5ac
21 changed files with 1791 additions and 1528 deletions

4
FAQ.md
View File

@@ -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/`.

View File

@@ -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

View File

@@ -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<TModel>` + 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

View File

@@ -0,0 +1,51 @@
using System.Text.Json;
using Microsoft.JSInterop;
namespace RpgRoller.Components.Pages;
public partial class Home
{
private async Task<T> RequestAsync<T>(string method, string path, object? payload = null)
{
var response = await JS.InvokeAsync<JsApiResponse>("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<T>(JsonOptions)!;
}
private async Task RequestWithoutPayloadAsync(string method, string path)
{
var response = await JS.InvokeAsync<JsApiResponse>("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; }
}
}

View File

@@ -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<UserSummary>(
"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<UserSummary>(
"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);
}
}

View File

@@ -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<CampaignSummary>(
"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;
}
}
}

View File

@@ -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<CharacterSummary>(
"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<CharacterSummary>(
"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);
}
}
}

View File

@@ -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<string?>("rpgRollerApi.getSessionValue", ScreenSessionKey);
if (string.Equals(storedScreen, "management", StringComparison.OrdinalIgnoreCase))
{
CurrentScreen = "management";
}
var storedPanel = await JS.InvokeAsync<string?>("rpgRollerApi.getSessionValue", MobilePanelSessionKey);
if (string.Equals(storedPanel, "log", StringComparison.OrdinalIgnoreCase))
{
MobilePanel = "log";
}
Guid? preferredCampaignId = null;
var storedCampaignId = await JS.InvokeAsync<string?>("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<HealthResponse>("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<IReadOnlyList<RulesetDefinition>>("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<MeResponse?> TryGetMeAsync()
{
try
{
return await RequestAsync<MeResponse>("GET", "/api/me");
}
catch (ApiRequestException ex) when (ex.StatusCode == 401)
{
return null;
}
}
private async Task ReloadCampaignsAsync(Guid? preferredCampaignId)
{
var campaigns = await RequestAsync<IReadOnlyList<CampaignSummary>>("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<CampaignDetails>("GET", $"/api/campaigns/{campaignId}");
CampaignLog = (await RequestAsync<IReadOnlyList<CampaignLogEntry>>("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;
}
}
}

View File

@@ -0,0 +1,55 @@
namespace RpgRoller.Components.Pages;
public sealed class FormState<TModel>
where TModel : new()
{
public TModel Model { get; } = new();
public Dictionary<string, string> 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
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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<SkillSummary>(
"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<SkillSummary>(
"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<RollResult>(
"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);
}
}

View File

@@ -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<RegisterFormModel> RegisterState { get; } = new();
private FormState<LoginFormModel> LoginState { get; } = new();
private FormState<CampaignFormModel> CampaignState { get; } = new();
private FormState<CharacterFormModel> CreateCharacterState { get; } = new();
private FormState<CharacterFormModel> EditCharacterState { get; } = new();
private FormState<SkillFormModel> CreateSkillState { get; } = new();
private FormState<SkillFormModel> 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<CampaignSummary> Campaigns { get; set; } = [];
private List<CampaignLogEntry> CampaignLog { get; set; } = [];
private List<RulesetDefinition> 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<Home>? 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<SkillSummary> 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";
}

View File

@@ -0,0 +1,23 @@
namespace RpgRoller.Components.Pages;
public partial class Home
{
private static void AddRequiredError(Dictionary<string, string> errors, string field, string? value, string message)
{
if (string.IsNullOrWhiteSpace(value))
{
errors[field] = message;
}
}
private static bool TryParseGuid(string? rawValue, Dictionary<string, string> errors, string field, string message, out Guid parsed)
{
if (Guid.TryParse(rawValue, out parsed))
{
return true;
}
errors[field] = message;
return false;
}
}

View File

@@ -5,15 +5,6 @@
<div class="@AppCssClass">
<p class="sr-only" aria-live="polite">@LiveAnnouncement</p>
@if (!IsInitialized)
{
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p>Connecting...</p>
</main>
}
else
{
@if (HasHealthIssue)
{
<section class="health-banner" role="alert">
@@ -25,72 +16,27 @@
</section>
}
@if (User is null)
@switch (CurrentView)
{
<main class="auth-shell">
case HomeViewMode.Loading:
<main class="loading-shell" aria-busy="true" aria-live="polite">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
<div class="auth-grid">
<section class="card auth-card">
<h2>Register</h2>
@if (!string.IsNullOrWhiteSpace(RegisterFormError))
{
<p class="form-error">@RegisterFormError</p>
}
<form class="form-grid" @onsubmit="RegisterAsync" @onsubmit:preventDefault>
<label for="register-username">Username</label>
<input id="register-username" @bind="RegisterForm.Username" @bind:event="oninput" autocomplete="username" />
@if (RegisterErrors.TryGetValue("username", out var registerUsernameError))
{
<p class="field-error">@registerUsernameError</p>
}
<label for="register-display-name">Display name</label>
<input id="register-display-name" @bind="RegisterForm.DisplayName" @bind:event="oninput" autocomplete="name" />
@if (RegisterErrors.TryGetValue("displayName", out var registerDisplayNameError))
{
<p class="field-error">@registerDisplayNameError</p>
}
<label for="register-password">Password</label>
<input id="register-password" type="password" @bind="RegisterForm.Password" @bind:event="oninput" autocomplete="new-password" />
@if (RegisterErrors.TryGetValue("password", out var registerPasswordError))
{
<p class="field-error">@registerPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
</form>
</section>
<section class="card auth-card">
<h2>Login</h2>
@if (!string.IsNullOrWhiteSpace(LoginFormError))
{
<p class="form-error">@LoginFormError</p>
}
<form class="form-grid" @onsubmit="LoginAsync" @onsubmit:preventDefault>
<label for="login-username">Username</label>
<input id="login-username" @bind="LoginForm.Username" @bind:event="oninput" autocomplete="username" />
@if (LoginErrors.TryGetValue("username", out var loginUsernameError))
{
<p class="field-error">@loginUsernameError</p>
}
<label for="login-password">Password</label>
<input id="login-password" type="password" @bind="LoginForm.Password" @bind:event="oninput" autocomplete="current-password" />
@if (LoginErrors.TryGetValue("password", out var loginPasswordError))
{
<p class="field-error">@loginPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
</form>
</section>
</div>
<p>Connecting...</p>
</main>
}
else
{
break;
case HomeViewMode.Anonymous:
<AuthSection
RegisterState="RegisterState"
LoginState="LoginState"
IsMutating="IsMutating"
StatusMessage="StatusMessage"
StatusIsError="StatusIsError"
RegisterSubmitted="RegisterAsync"
LoginSubmitted="LoginAsync" />
break;
case HomeViewMode.Workspace:
<div class="workspace-shell">
<header class="workspace-header">
<div class="header-group brand">
@@ -98,7 +44,7 @@
<p>Tabletop utility cockpit</p>
</div>
<div class="header-group context">
<p><strong>@User.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
<p><strong>@User!.DisplayName</strong> <span class="muted">(@User.Username)</span></p>
<p>Campaign: <strong>@(SelectedCampaignName ?? "No campaign selected")</strong></p>
<p>Active: <strong>@(ActiveCharacterName ?? "None selected")</strong></p>
</div>
@@ -120,7 +66,7 @@
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
@if (CurrentScreen == "play")
@if (IsPlayScreen)
{
<main class="play-screen @(MobilePanel == "log" ? "mobile-log" : "mobile-character")">
<CharacterPanel
@@ -162,250 +108,77 @@
<button type="button" class="switch @(MobilePanel == "log" ? "active" : string.Empty)" @onclick="SetMobilePanelLogAsync">Log</button>
</nav>
}
else
@if (IsManagementScreen)
{
<main class="management-screen">
<section class="card">
<h2>Campaign Selector</h2>
@if (Campaigns.Count == 0)
{
<p class="empty">No campaigns yet.</p>
}
else
{
<label for="campaign-select">Campaign</label>
<select id="campaign-select" @onchange="OnCampaignSelectionChangedAsync">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
}
</select>
}
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
</section>
<section class="card">
<h2>Create Campaign</h2>
@if (!string.IsNullOrWhiteSpace(CampaignFormError))
{
<p class="form-error">@CampaignFormError</p>
}
<form class="form-grid" @onsubmit="CreateCampaignAsync" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignForm.Name" @bind:event="oninput" />
@if (CampaignErrors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
}
<label for="campaign-ruleset">Ruleset</label>
<select id="campaign-ruleset" @bind="CampaignForm.RulesetId">
<option value="">Select ruleset</option>
@foreach (var ruleset in Rulesets)
{
<option value="@ruleset.Id">@ruleset.Name</option>
}
</select>
@if (CampaignErrors.TryGetValue("rulesetId", out var campaignRulesetError))
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
</form>
</section>
<section class="card">
<h2>Campaign Details</h2>
@if (SelectedCampaign is null)
{
<p class="empty">No campaign selected.</p>
}
else
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
</section>
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="OpenCreateCharacterModal">Create Character</button>
</div>
@if (SelectedCampaign is null)
{
<p class="empty">Select a campaign first.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<ul class="management-list">
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => OpenEditCharacterModal(character)">Edit</button>
</div>
</li>
}
</ul>
}
</section>
</main>
<CampaignManagementPanel
Campaigns="Campaigns"
SelectedCampaignId="SelectedCampaignId"
SelectedCampaignName="SelectedCampaignName"
SelectedCampaign="SelectedCampaign"
CampaignState="CampaignState"
Rulesets="Rulesets"
IsMutating="IsMutating"
OwnerLabel="OwnerLabel"
CanEditCharacter="CanEditCharacter"
CampaignSelectionChanged="OnCampaignSelectionChangedAsync"
CreateCampaignSubmitted="CreateCampaignAsync"
CreateCharacterRequested="OpenCreateCharacterModal"
EditCharacterRequested="OpenEditCharacterModal" />
}
</div>
}
break;
}
</div>
@if (ShowCreateCharacterModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create character">
<h2>Create Character</h2>
@if (!string.IsNullOrWhiteSpace(CharacterFormError))
{
<p class="form-error">@CharacterFormError</p>
}
<form class="form-grid" @onsubmit="CreateCharacterAsync" @onsubmit:preventDefault>
<label for="character-create-name">Character name</label>
<input id="character-create-name" @bind="CharacterForm.Name" @bind:event="oninput" />
@if (CharacterErrors.TryGetValue("name", out var createCharacterNameError))
{
<p class="field-error">@createCharacterNameError</p>
}
<label for="character-create-campaign">Campaign</label>
<select id="character-create-campaign" @bind="CharacterForm.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id">@campaign.Name</option>
}
</select>
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Create Character</button>
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<CharacterFormModal
Visible="ShowCreateCharacterModal"
Title="Create Character"
SubmitLabel="Create Character"
NameInputId="character-create-name"
CampaignInputId="character-create-campaign"
FormState="CreateCharacterState"
Campaigns="Campaigns"
IsMutating="IsMutating"
SubmitRequested="CreateCharacterAsync"
CancelRequested="CloseCharacterModals" />
@if (ShowEditCharacterModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit character">
<h2>Edit Character</h2>
@if (!string.IsNullOrWhiteSpace(EditCharacterFormError))
{
<p class="form-error">@EditCharacterFormError</p>
}
<form class="form-grid" @onsubmit="UpdateCharacterAsync" @onsubmit:preventDefault>
<label for="character-edit-name">Character name</label>
<input id="character-edit-name" @bind="EditCharacterForm.Name" @bind:event="oninput" />
@if (EditCharacterErrors.TryGetValue("name", out var editCharacterNameError))
{
<p class="field-error">@editCharacterNameError</p>
}
<label for="character-edit-campaign">Campaign</label>
<select id="character-edit-campaign" @bind="EditCharacterForm.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id">@campaign.Name</option>
}
</select>
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Save Character</button>
<button type="button" class="ghost" @onclick="CloseCharacterModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<CharacterFormModal
Visible="ShowEditCharacterModal"
Title="Edit Character"
SubmitLabel="Save Character"
NameInputId="character-edit-name"
CampaignInputId="character-edit-campaign"
FormState="EditCharacterState"
Campaigns="Campaigns"
IsMutating="IsMutating"
SubmitRequested="UpdateCharacterAsync"
CancelRequested="CloseCharacterModals" />
@if (ShowCreateSkillModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Create skill">
<h2>Create Skill</h2>
@if (!string.IsNullOrWhiteSpace(SkillFormError))
{
<p class="form-error">@SkillFormError</p>
}
<form class="form-grid" @onsubmit="CreateSkillAsync" @onsubmit:preventDefault>
<label for="skill-create-name">Skill name</label>
<input id="skill-create-name" @bind="SkillForm.Name" @bind:event="oninput" />
@if (SkillErrors.TryGetValue("name", out var createSkillNameError))
{
<p class="field-error">@createSkillNameError</p>
}
<label for="skill-create-expression">Expression</label>
<input id="skill-create-expression" @bind="SkillForm.DiceRollDefinition" @bind:event="oninput" />
@if (SkillErrors.TryGetValue("diceRollDefinition", out var createSkillExpressionError))
{
<p class="field-error">@createSkillExpressionError</p>
}
@if (IsSelectedCampaignD6)
{
<label for="skill-create-wild-dice">Wild dice</label>
<input id="skill-create-wild-dice" type="number" min="1" step="1" @bind="SkillForm.WildDice" />
@if (SkillErrors.TryGetValue("wildDice", out var createSkillWildDiceError))
{
<p class="field-error">@createSkillWildDiceError</p>
}
<label for="skill-create-allow-fumble">Allow fumble</label>
<input id="skill-create-allow-fumble" type="checkbox" @bind="SkillForm.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Create Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<SkillFormModal
Visible="ShowCreateSkillModal"
IsD6="IsSelectedCampaignD6"
Title="Create Skill"
SubmitLabel="Create Skill"
NameInputId="skill-create-name"
ExpressionInputId="skill-create-expression"
WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble"
FormState="CreateSkillState"
IsMutating="IsMutating"
SubmitRequested="CreateSkillAsync"
CancelRequested="CloseSkillModals" />
@if (ShowEditSkillModal)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="Edit skill">
<h2>Edit Skill</h2>
@if (!string.IsNullOrWhiteSpace(EditSkillFormError))
{
<p class="form-error">@EditSkillFormError</p>
}
<form class="form-grid" @onsubmit="UpdateSkillAsync" @onsubmit:preventDefault>
<label for="skill-edit-name">Skill name</label>
<input id="skill-edit-name" @bind="EditSkillForm.Name" @bind:event="oninput" />
@if (EditSkillErrors.TryGetValue("name", out var editSkillNameError))
{
<p class="field-error">@editSkillNameError</p>
}
<label for="skill-edit-expression">Expression</label>
<input id="skill-edit-expression" @bind="EditSkillForm.DiceRollDefinition" @bind:event="oninput" />
@if (EditSkillErrors.TryGetValue("diceRollDefinition", out var editSkillExpressionError))
{
<p class="field-error">@editSkillExpressionError</p>
}
@if (IsSelectedCampaignD6)
{
<label for="skill-edit-wild-dice">Wild dice</label>
<input id="skill-edit-wild-dice" type="number" min="1" step="1" @bind="EditSkillForm.WildDice" />
@if (EditSkillErrors.TryGetValue("wildDice", out var editSkillWildDiceError))
{
<p class="field-error">@editSkillWildDiceError</p>
}
<label for="skill-edit-allow-fumble">Allow fumble</label>
<input id="skill-edit-allow-fumble" type="checkbox" @bind="EditSkillForm.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">Save Skill</button>
<button type="button" class="ghost" @onclick="CloseSkillModals">Cancel</button>
</div>
</form>
</section>
</div>
}
<SkillFormModal
Visible="ShowEditSkillModal"
IsD6="IsSelectedCampaignD6"
Title="Edit Skill"
SubmitLabel="Save Skill"
NameInputId="skill-edit-name"
ExpressionInputId="skill-edit-expression"
WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble"
FormState="EditSkillState"
IsMutating="IsMutating"
SubmitRequested="UpdateSkillAsync"
CancelRequested="CloseSkillModals" />

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@attribute [ExcludeFromCodeCoverage]
<main class="auth-shell">
<h1>RpgRoller</h1>
<p class="auth-subtitle">Register or log in to join a campaign session.</p>
@if (!string.IsNullOrWhiteSpace(StatusMessage))
{
<p class="status-message @(StatusIsError ? "error" : "success")">@StatusMessage</p>
}
<div class="auth-grid">
<section class="card auth-card">
<h2>Register</h2>
@if (!string.IsNullOrWhiteSpace(RegisterState.ErrorMessage))
{
<p class="form-error">@RegisterState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitRegisterAsync" @onsubmit:preventDefault>
<label for="register-username">Username</label>
<input id="register-username" @bind="RegisterState.Model.Username" @bind:event="oninput" autocomplete="username" />
@if (RegisterState.Errors.TryGetValue("username", out var registerUsernameError))
{
<p class="field-error">@registerUsernameError</p>
}
<label for="register-display-name">Display name</label>
<input id="register-display-name" @bind="RegisterState.Model.DisplayName" @bind:event="oninput" autocomplete="name" />
@if (RegisterState.Errors.TryGetValue("displayName", out var registerDisplayNameError))
{
<p class="field-error">@registerDisplayNameError</p>
}
<label for="register-password">Password</label>
<input id="register-password" type="password" @bind="RegisterState.Model.Password" @bind:event="oninput" autocomplete="new-password" />
@if (RegisterState.Errors.TryGetValue("password", out var registerPasswordError))
{
<p class="field-error">@registerPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Registering..." : "Register")</button>
</form>
</section>
<section class="card auth-card">
<h2>Login</h2>
@if (!string.IsNullOrWhiteSpace(LoginState.ErrorMessage))
{
<p class="form-error">@LoginState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitLoginAsync" @onsubmit:preventDefault>
<label for="login-username">Username</label>
<input id="login-username" @bind="LoginState.Model.Username" @bind:event="oninput" autocomplete="username" />
@if (LoginState.Errors.TryGetValue("username", out var loginUsernameError))
{
<p class="field-error">@loginUsernameError</p>
}
<label for="login-password">Password</label>
<input id="login-password" type="password" @bind="LoginState.Model.Password" @bind:event="oninput" autocomplete="current-password" />
@if (LoginState.Errors.TryGetValue("password", out var loginPasswordError))
{
<p class="field-error">@loginPasswordError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Logging in..." : "Login")</button>
</form>
</section>
</div>
</main>
@code {
[Parameter]
public FormState<RegisterFormModel> RegisterState { get; set; } = new();
[Parameter]
public FormState<LoginFormModel> 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();
}
}

View File

@@ -0,0 +1,139 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
<main class="management-screen">
<section class="card">
<h2>Campaign Selector</h2>
@if (Campaigns.Count == 0)
{
<p class="empty">No campaigns yet.</p>
}
else
{
<label for="campaign-select">Campaign</label>
<select id="campaign-select" @onchange="CampaignSelectionChanged">
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id" selected="@(campaign.Id == SelectedCampaignId)">@campaign.Name (@campaign.RulesetId)</option>
}
</select>
}
<p class="muted">Current campaign in this tab: <strong>@(SelectedCampaignName ?? "None selected")</strong></p>
</section>
<section class="card">
<h2>Create Campaign</h2>
@if (!string.IsNullOrWhiteSpace(CampaignState.ErrorMessage))
{
<p class="form-error">@CampaignState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="CreateCampaignSubmitted" @onsubmit:preventDefault>
<label for="campaign-name">Campaign name</label>
<input id="campaign-name" @bind="CampaignState.Model.Name" @bind:event="oninput" />
@if (CampaignState.Errors.TryGetValue("name", out var campaignNameError))
{
<p class="field-error">@campaignNameError</p>
}
<label for="campaign-ruleset">Ruleset</label>
<select id="campaign-ruleset" @bind="CampaignState.Model.RulesetId">
<option value="">Select ruleset</option>
@foreach (var ruleset in Rulesets)
{
<option value="@ruleset.Id">@ruleset.Name</option>
}
</select>
@if (CampaignState.Errors.TryGetValue("rulesetId", out var campaignRulesetError))
{
<p class="field-error">@campaignRulesetError</p>
}
<button type="submit" disabled="@IsMutating">@(IsMutating ? "Creating..." : "Create Campaign")</button>
</form>
</section>
<section class="card">
<h2>Campaign Details</h2>
@if (SelectedCampaign is null)
{
<p class="empty">No campaign selected.</p>
}
else
{
<p>Name: <strong>@SelectedCampaign.Name</strong></p>
<p>Ruleset: <strong>@SelectedCampaign.RulesetId</strong></p>
<p>GM: <strong>@SelectedCampaign.Gm.DisplayName</strong> <span class="muted">(@SelectedCampaign.Gm.Username)</span></p>
<p>Characters visible: <strong>@SelectedCampaign.Characters.Count</strong></p>
}
</section>
<section class="card">
<div class="section-head">
<h2>Character Management</h2>
<button type="button" disabled="@(IsMutating || SelectedCampaign is null)" @onclick="CreateCharacterRequested">Create Character</button>
</div>
@if (SelectedCampaign is null)
{
<p class="empty">Select a campaign first.</p>
}
else if (SelectedCampaign.Characters.Count == 0)
{
<p class="empty">No characters in this campaign yet.</p>
}
else
{
<ul class="management-list">
@foreach (var character in SelectedCampaign.Characters)
{
<li>
<div><strong>@character.Name</strong><p class="muted">Owner: @OwnerLabel(character.OwnerUserId)</p></div>
<div class="inline-actions">
<button type="button" disabled="@(IsMutating || !CanEditCharacter(character))" @onclick="() => EditCharacterRequested.InvokeAsync(character)">Edit</button>
</div>
</li>
}
</ul>
}
</section>
</main>
@code {
[Parameter]
public IReadOnlyList<CampaignSummary> Campaigns { get; set; } = [];
[Parameter]
public Guid? SelectedCampaignId { get; set; }
[Parameter]
public string? SelectedCampaignName { get; set; }
[Parameter]
public CampaignDetails? SelectedCampaign { get; set; }
[Parameter]
public FormState<CampaignFormModel> CampaignState { get; set; } = new();
[Parameter]
public IReadOnlyList<RulesetDefinition> Rulesets { get; set; } = [];
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public Func<Guid, string> OwnerLabel { get; set; } = _ => string.Empty;
[Parameter]
public Func<CharacterSummary, bool> CanEditCharacter { get; set; } = _ => false;
[Parameter]
public EventCallback<ChangeEventArgs> CampaignSelectionChanged { get; set; }
[Parameter]
public EventCallback CreateCampaignSubmitted { get; set; }
[Parameter]
public EventCallback CreateCharacterRequested { get; set; }
[Parameter]
public EventCallback<CharacterSummary> EditCharacterRequested { get; set; }
}

View File

@@ -0,0 +1,78 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@using RpgRoller.Contracts
@attribute [ExcludeFromCodeCoverage]
@if (Visible)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="@Title">
<h2>@Title</h2>
@if (!string.IsNullOrWhiteSpace(FormState.ErrorMessage))
{
<p class="form-error">@FormState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Character name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
@if (FormState.Errors.TryGetValue("name", out var nameError))
{
<p class="field-error">@nameError</p>
}
<label for="@CampaignInputId">Campaign</label>
<select id="@CampaignInputId" @bind="FormState.Model.CampaignId">
<option value="">Select campaign</option>
@foreach (var campaign in Campaigns)
{
<option value="@campaign.Id">@campaign.Name</option>
}
</select>
@if (FormState.Errors.TryGetValue("campaignId", out var campaignError))
{
<p class="field-error">@campaignError</p>
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
</div>
</form>
</section>
</div>
}
@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<CharacterFormModel> FormState { get; set; } = new();
[Parameter]
public IReadOnlyList<CampaignSummary> 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();
}
}

View File

@@ -0,0 +1,88 @@
@using System.Diagnostics.CodeAnalysis
@using RpgRoller.Components.Pages
@attribute [ExcludeFromCodeCoverage]
@if (Visible)
{
<div class="modal-overlay" role="presentation">
<section class="modal-card" role="dialog" aria-modal="true" aria-label="@Title">
<h2>@Title</h2>
@if (!string.IsNullOrWhiteSpace(FormState.ErrorMessage))
{
<p class="form-error">@FormState.ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@NameInputId">Skill name</label>
<input id="@NameInputId" @bind="FormState.Model.Name" @bind:event="oninput" />
@if (FormState.Errors.TryGetValue("name", out var skillNameError))
{
<p class="field-error">@skillNameError</p>
}
<label for="@ExpressionInputId">Expression</label>
<input id="@ExpressionInputId" @bind="FormState.Model.DiceRollDefinition" @bind:event="oninput" />
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{
<p class="field-error">@expressionError</p>
}
@if (IsD6)
{
<label for="@WildDiceInputId">Wild dice</label>
<input id="@WildDiceInputId" type="number" min="1" step="1" @bind="FormState.Model.WildDice" />
@if (FormState.Errors.TryGetValue("wildDice", out var wildDiceError))
{
<p class="field-error">@wildDiceError</p>
}
<label for="@AllowFumbleInputId">Allow fumble</label>
<input id="@AllowFumbleInputId" type="checkbox" @bind="FormState.Model.AllowFumble" />
}
<div class="inline-actions">
<button type="submit" disabled="@IsMutating">@SubmitLabel</button>
<button type="button" class="ghost" @onclick="CancelRequested">Cancel</button>
</div>
</form>
</section>
</div>
}
@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<SkillFormModel> 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();
}
}

View File

@@ -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<TModel>` 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.